From c7bd71a3e19fdfe6e8ebaaddf2bdb3672c575e03 Mon Sep 17 00:00:00 2001 From: Barely Removable Date: Sat, 22 Aug 2026 09:22:41 -0700 Subject: [PATCH] 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. --- .gitignore | 4 + Dockerfile | 16 + README.md | 289 +++++++++++++ app.py | 410 ++++++++++++++++++ cardimage.py | 802 +++++++++++++++++++++++++++++++++++ docker-compose.yml | 19 + nginx/card-grader.conf | 43 ++ static/app.js | 572 +++++++++++++++++++++++++ static/icon-180.png | Bin 0 -> 981 bytes static/icon-192.png | Bin 0 -> 1045 bytes static/icon-512-maskable.png | Bin 0 -> 2733 bytes static/icon-512.png | Bin 0 -> 3050 bytes static/icon.svg | 6 + static/index.html | 90 ++++ static/manifest.json | 17 + static/style.css | 367 ++++++++++++++++ static/sw.js | 62 +++ store.py | 202 +++++++++ vision.py | 719 +++++++++++++++++++++++++++++++ 19 files changed, 3618 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app.py create mode 100644 cardimage.py create mode 100644 docker-compose.yml create mode 100644 nginx/card-grader.conf create mode 100644 static/app.js create mode 100644 static/icon-180.png create mode 100644 static/icon-192.png create mode 100644 static/icon-512-maskable.png create mode 100644 static/icon-512.png create mode 100644 static/icon.svg create mode 100644 static/index.html create mode 100644 static/manifest.json create mode 100644 static/style.css create mode 100644 static/sw.js create mode 100644 store.py create mode 100644 vision.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f3d31ca --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +grades.db +data/ +__pycache__/ +*.pyc diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6103ac0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +# Only optional deps (see README) — the app itself is stdlib only. +RUN pip install --no-cache-dir anthropic pillow + +WORKDIR /app +COPY app.py cardimage.py store.py vision.py ./ +COPY static/ ./static/ + +# grades.db lives here; mount a volume at /data to persist it across +# container recreates and image updates. +ENV CARD_GRADER_DB_PATH=/data/grades.db +ENV PORT=8778 +EXPOSE 8778 + +CMD ["python3", "app.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..e84b8df --- /dev/null +++ b/README.md @@ -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. diff --git a/app.py b/app.py new file mode 100644 index 0000000..cdaae8d --- /dev/null +++ b/app.py @@ -0,0 +1,410 @@ +#!/usr/bin/env python3 +"""Card Grader — local web app. + +Stdlib only, except the optional `anthropic` and `pillow` packages (see +README.md). Run it with: python3 app.py +Then open http://localhost:8778 + +Data lives in grades.db next to this file. Back that one file up and you have +backed up your whole grading history. + +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," estimated from photos, with a local +history of what you've checked. See cardimage.py for the actual mechanism: +two of PSA's four categories (centering, edge whitening) are measured +directly from the pixels rather than eyeballed by the model. +""" + +import base64 +import io +import json +import os +import sys +import time +import urllib.parse +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import cardimage +import store +import vision + +# A photo arrives base64-encoded inside JSON (avoids hand-rolling multipart +# parsing on top of the stdlib server). Cap it so a stray upload can't wedge +# the process reading an unbounded body. +MAX_UPLOAD_BYTES = 12 * 1024 * 1024 +# More angles help — front, back, corner close-ups — but past a handful the +# extra photos cost tokens without adding evidence. +MAX_GRADE_IMAGES = 6 +# Small enough to sit in a history list without bloating the database; big +# enough to still recognise the card at a glance. +THUMBNAIL_TARGET_PX = 320 + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +STATIC_DIR = os.path.join(BASE_DIR, "static") +PORT = int(os.environ.get("PORT", "8778")) + +# Set when this app is reverse-proxied under a sub-path (e.g. "/cards") +# rather than served at its own domain root. The four files that reference +# absolute URLs (index.html, manifest.json, sw.js, app.js) get this spliced +# in for every "__BASE__" placeholder — see _static_templated. Everything +# else (routing included) works the same either way, since nginx forwards +# the full "/cards/..." path through unchanged and _route() below strips it +# right back off before matching. +BASE_PATH = (os.environ.get("CARD_GRADER_BASE_PATH") or "").rstrip("/") +TEMPLATED_STATIC = {"index.html", "manifest.json", "sw.js", "app.js"} + +# Set CARD_GRADER_LOCK=1 when exposing this beyond your own machine. It stops +# visitors rewriting your stored settings — including swapping the model to +# the most expensive one, or replacing your API key. Grading still works +# normally; only the settings write is refused. +SETTINGS_LOCKED = os.environ.get("CARD_GRADER_LOCK", "").strip() not in ("", "0") + +CONTENT_TYPES = { + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "application/javascript; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".ico": "image/x-icon", + ".json": "application/manifest+json", +} + + +# --------------------------------------------------------------- helpers + + +def _decode_uploaded_images(raw_images): + """Decode base64 uploads. Returns (images, error_message). + + The format is decided from the bytes, not the filename — phones hand + over names this code has no business trusting (no extension, a + content:// URI, .HEIC), and an unreadable *name* is no reason to reject + a perfectly readable *photo*. See cardimage.normalize_upload. + """ + images, total = [], 0 + for entry in raw_images or []: + raw = (entry or {}).get("image_base64") or "" + filename = (entry or {}).get("filename") or "upload" + if not raw: + return None, "No image received" + try: + image_bytes = base64.b64decode(raw, validate=True) + except Exception: + return None, "Image data was not valid base64" + total += len(image_bytes) + if total > MAX_UPLOAD_BYTES: + return None, "Images are {:.1f} MB combined; the limit is {} MB.".format( + total / 1024 / 1024, MAX_UPLOAD_BYTES // 1024 // 1024) + image_bytes, filename, error = cardimage.normalize_upload(image_bytes, filename) + if error: + return None, error + images.append((image_bytes, filename)) + return images, None + + +def _public_settings(): + """Settings safe to hand to a browser. + + The stored API key never leaves the server. Once this is reachable by + anyone but you — which is the whole point of putting it behind a tunnel + for friends — returning the raw key would hand every visitor the ability + to spend your Anthropic credits anywhere they like. They get a boolean + saying whether one is configured, which is all the UI needs. + """ + s = store.get_settings() + return { + "vision_model": s.get("vision_model"), + "vision_effort": s.get("vision_effort"), + "server_key_configured": bool(s.get("anthropic_api_key")), + "settings_locked": SETTINGS_LOCKED, + } + + +def _make_thumbnail(image_bytes): + """Small base64 JPEG for the history list, or None if Pillow is missing.""" + if not cardimage.available(): + return None + try: + from PIL import Image + img = Image.open(io.BytesIO(image_bytes)) + img.load() + if img.mode not in ("RGB", "L"): + img = img.convert("RGB") + factor = THUMBNAIL_TARGET_PX / float(max(img.size)) + if factor < 1: + img = img.resize( + (max(1, int(img.width * factor)), max(1, int(img.height * factor))), + Image.LANCZOS) + buffer = io.BytesIO() + img.convert("RGB").save(buffer, format="JPEG", quality=78) + return "data:image/jpeg;base64," + base64.standard_b64encode( + buffer.getvalue()).decode("ascii") + except Exception: + return None + + +# ---------------------------------------------------------------- handler + + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + server_version = "CardGrader" + + def log_message(self, fmt, *args): + if str(args[1] if len(args) > 1 else "").startswith(("4", "5")): + sys.stderr.write(" {} {}\n".format(self.address_string(), fmt % args)) + + # ------------------------------------------------------------ helpers + + def _send(self, code, body, content_type="application/json; charset=utf-8", + cache_control="no-store"): + if isinstance(body, str): + body = body.encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", cache_control) + self.end_headers() + self.wfile.write(body) + + def _json(self, obj, code=200): + self._send(code, json.dumps(obj, default=str)) + + def _error(self, message, code=400): + self._json({"error": message}, code) + + def _body(self): + length = int(self.headers.get("Content-Length") or 0) + if not length: + return {} + try: + return json.loads(self.rfile.read(length).decode("utf-8")) + except ValueError: + return {} + + def _route(self): + path = urllib.parse.urlparse(self.path).path + # nginx forwards the full "/cards/..." URI through unchanged (same + # pattern as this box's other proxied apps, each of which handles + # its own URL-base) — strip it back off so route matching below + # doesn't need to know whether it's mounted at the domain root or + # under a sub-path. + if BASE_PATH and (path == BASE_PATH or path.startswith(BASE_PATH + "/")): + path = path[len(BASE_PATH):] + return path.rstrip("/") or "/" + + def _static(self, relative): + safe = os.path.normpath(relative).lstrip(os.sep) + path = os.path.join(STATIC_DIR, safe) + if not path.startswith(STATIC_DIR) or not os.path.isfile(path): + return self._error("Not found", 404) + ext = os.path.splitext(path)[1] + if os.path.basename(path) in TEMPLATED_STATIC: + with open(path, "r", encoding="utf-8") as handle: + body = handle.read().replace("__BASE__", BASE_PATH).encode("utf-8") + else: + with open(path, "rb") as handle: + body = handle.read() + # "no-cache" (revalidate every time), NOT "no-store". They sound + # interchangeable and are not: Chrome refuses to register a service + # worker whose script came back no-store, so the whole install-as-an- + # app path silently dies. no-cache still guarantees you never get a + # stale file, since the browser revalidates before using it. + self._send(200, body, CONTENT_TYPES.get(ext, "application/octet-stream"), + cache_control="no-cache") + + # ---------------------------------------------------------------- GET + + def do_GET(self): + route = self._route() + try: + if route == "/" or route == "/index.html": + return self._static("index.html") + # Served from the root deliberately: a service worker's scope is + # its own directory, so one living at /static/sw.js could only + # ever control /static/ — not the app. Same file, root URL. + if route == "/sw.js": + return self._static("sw.js") + if route.startswith("/static/"): + return self._static(route[len("/static/"):]) + if route == "/api/settings": + return self._json(_public_settings()) + if route == "/api/vision-models": + return self._json(vision.price_guide()) + if route == "/api/history": + return self._json({"grades": store.list_grades()}) + if route.startswith("/api/history/"): + grade = store.get_grade(int(route.split("/")[3])) + if not grade: + return self._error("No such grade", 404) + return self._json({"grade": grade}) + return self._error("Not found", 404) + except (ValueError, IndexError): + return self._error("Bad request path", 400) + except Exception as exc: + return self._error("Server error: {}".format(exc), 500) + + # --------------------------------------------------------------- POST + + def do_POST(self): + route = self._route() + try: + body = self._body() + if route == "/api/settings": + if SETTINGS_LOCKED: + return self._error( + "Settings are locked on this server. Use your own API " + "key in this browser instead.", 403) + store.save_settings(body) + return self._json(_public_settings()) + if route == "/api/grade": + return self._grade_card(body) + return self._error("Not found", 404) + except (ValueError, IndexError): + return self._error("Bad request", 400) + except Exception as exc: + return self._error("Server error: {}".format(exc), 500) + + def do_PATCH(self): + route = self._route() + try: + if route.startswith("/api/history/"): + grade_id = int(route.split("/")[3]) + body = self._body() + if store.get_grade(grade_id) is None: + return self._error("No such grade", 404) + grade = store.update_grade_label(grade_id, body.get("label")) + return self._json({"grade": grade}) + return self._error("Not found", 404) + except (ValueError, IndexError): + return self._error("Bad request", 400) + except Exception as exc: + return self._error("Server error: {}".format(exc), 500) + + def do_DELETE(self): + route = self._route() + try: + if route.startswith("/api/history/"): + store.delete_grade(int(route.split("/")[3])) + return self._json({"ok": True}) + return self._error("Not found", 404) + except (ValueError, IndexError): + return self._error("Bad request", 400) + except Exception as exc: + return self._error("Server error: {}".format(exc), 500) + + # ------------------------------------------------------------- grade + + def _grade_card(self, body): + """Estimate the PSA grade for a card, from uploaded photo(s). + + Always returns the estimate. Saves it to history too, unless the + caller explicitly opts out with save=false — a one-off sanity check + before you've decided a card is worth logging. + """ + settings = store.get_settings() + + raw_images = body.get("images") or [] + if not isinstance(raw_images, list) or not raw_images: + return self._error("Send at least one image.", 400) + if len(raw_images) > MAX_GRADE_IMAGES: + return self._error("Send at most {} images.".format(MAX_GRADE_IMAGES), 400) + images, error = _decode_uploaded_images(raw_images) + if error: + return self._error(error, 400) + + model = body.get("model") if body.get("model") in vision.MODELS else settings.get("vision_model") + + # A key sent with the request wins over the server's own. That is what + # makes this shareable: hand a friend the URL and they can bring their + # own Anthropic key rather than spending yours. Never stored — it + # lives in their browser and is used for this one call. + caller_key = (body.get("api_key") or "").strip() or None + + print("[grade] calling vision model={} on {} image(s){}…".format( + model, len(images), " (caller key)" if caller_key else ""), flush=True) + t0 = time.time() + try: + result = vision.grade_card( + images, + caller_key or settings.get("anthropic_api_key") or None, + model=model, + effort=settings.get("vision_effort"), + ) + except vision.VisionError as exc: + return self._error(str(exc), 502) + print("[grade] done in {:.1f}s".format(time.time() - t0), flush=True) + + grade = { + "image_count": len(images), + "closeups": result["closeups"], + "card_type": result["card_type"], + "card_note": result["card_note"], + "edge_measurements": result["edge_measurements"], + "centering_measurement": result["centering_measurement"], + "estimated_grade": result["estimated_grade"], + "grade_low": result["grade_low"], + "grade_high": result["grade_high"], + "confidence": result["confidence"], + "categories": result["categories"], + "limitations": result["limitations"], + "note": result["note"], + "usage": result["usage"], + "estimated_cost": vision.estimate_cost(result["usage"]), + } + + grade_id = None + if body.get("save", True): + thumbnail = _make_thumbnail(images[0][0]) + grade_id = store.save_grade(grade, thumbnail=thumbnail, label=body.get("label")) + + return self._json({"grade": grade, "grade_id": grade_id}, 201) + + +def lan_ip(): + """Best-effort LAN address, so a phone can reach this.""" + import socket + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + sock.connect(("8.8.8.8", 80)) + return sock.getsockname()[0] + except Exception: + return None + finally: + sock.close() + + +class Server(ThreadingHTTPServer): + allow_reuse_address = True + daemon_threads = True + + +def main(): + store.init() + try: + server = Server(("0.0.0.0", PORT), Handler) + except OSError as exc: + print("\n Could not start on port {}: {}".format(PORT, exc)) + print(" Something else is using it. Free it with:") + print(" lsof -ti :{} | xargs kill\n".format(PORT)) + raise SystemExit(1) + ip = lan_ip() + + print("\n Card Grader") + print(" " + "-" * 42) + print(" On this Mac: http://localhost:{}".format(PORT)) + if ip: + print(" On your phone: http://{}:{} (same Wi-Fi)".format(ip, PORT)) + print(" Database: {}".format(store.DB_PATH)) + print("\n Ctrl-C to stop.\n") + + try: + server.serve_forever() + except KeyboardInterrupt: + print("\n Stopped.") + server.shutdown() + + +if __name__ == "__main__": + main() diff --git a/cardimage.py b/cardimage.py new file mode 100644 index 0000000..89fb233 --- /dev/null +++ b/cardimage.py @@ -0,0 +1,802 @@ +"""Corner close-ups for grading, cut out of a full-card photo. + +Corners are the category the grader struggled with most, and the reason is +mechanical rather than a prompt problem: a card corner is a tiny fraction of +the frame, so once a full-card photo is scaled down for the vision model +there are barely any pixels left where the whitening and fraying actually +live. A human grader solves this with a loupe. This does the same thing — +find the card in the photo, cut out each of the four corners, and upscale +them into their own images so the detail survives. + +Pillow is an optional dependency. Without it everything still works, just +without the close-ups, so this never becomes a hard requirement. +""" + +import io + +try: + from PIL import Image, ImageChops, ImageFilter, ImageOps +except ImportError: + Image = None + ImageChops = None + ImageFilter = None + ImageOps = None + +# Fraction of the card's width/height each corner crop covers. A card corner's +# actual wear lives in the outer few millimetres, but a crop that tight loses +# the context needed to judge whether an edge is cut straight. +CORNER_FRACTION = 0.28 +# How deep an edge strip reaches into the card, as a fraction of the +# perpendicular dimension. Kept deliberately shallow: whitening sits in the +# outermost millimetre or two, so a deeper strip is mostly card interior and +# the wear ends up a sliver at one end of a frame full of artwork — which is +# exactly how it gets overlooked. Shallow enough that the cut edge dominates +# what's on screen, with just enough border either side to give it context. +EDGE_FRACTION = 0.07 +# Upscale target for the long edge of each crop. Large enough that fine +# whitening survives, small enough to stay well inside the model's per-image +# cap (and its token cost). +CORNER_TARGET_PX = 700 +# Edge strips are long and thin, so magnifying them by their LONG axis (the +# way a squarish corner crop is handled) does nothing useful — that axis is +# already big. What matters is how many pixels lie across the strip, since +# that's the direction a whitening band is measured in. So these target the +# short axis, with a cap on the long one to stay inside the model's per-image +# pixel limit. +EDGE_SHORT_TARGET_PX = 340 +EDGE_LONG_CAP_PX = 2500 +# Surface inspection, as a band-pass rather than a plain high-pass. A plain +# high-pass keeps the very finest detail, which on a printed card means the +# halftone dot rosettes — they swamp the picture and hide the very marks +# being looked for. Scratches and print lines sit in a band between those +# dots and the artwork itself, so the fine radius blurs the dots away and +# the coarse one takes the artwork out, leaving what's in between. +SURFACE_FINE_RADIUS = 1.4 +SURFACE_COARSE_RADIUS = 6.0 +SURFACE_AUTOCONTRAST_CUTOFF = 0.4 +# The card face is split into quadrants for surface inspection, so each one +# keeps enough resolution to show a hairline scratch. Rows x columns. +SURFACE_TILES = (2, 2) +SURFACE_TILE_TARGET_PX = 1150 +SURFACE_QUADRANTS = ("upper-left", "upper-right", "lower-left", "lower-right") +# Below this the source photo has no detail worth zooming into — upscaling it +# would just produce a convincing-looking blur for the model to over-read. +MIN_SOURCE_PX = 600 + +CORNERS = ("top-left", "top-right", "bottom-left", "bottom-right") +EDGES = ("top-edge", "right-edge", "bottom-edge", "left-edge") + + +def available(): + return Image is not None + + +# Magic numbers, so an image is identified by what it actually is rather than +# by what its filename claims. Phones routinely hand over names the extension +# check can't cope with — no extension at all, a content:// URI, or .HEIC — +# and rejecting a perfectly readable photo over its name is indefensible. +_SIGNATURES = ( + (b"\x89PNG\r\n\x1a\n", "png"), + (b"\xff\xd8\xff", "jpeg"), + (b"GIF87a", "gif"), + (b"GIF89a", "gif"), + (b"BM", "bmp"), + (b"II*\x00", "tiff"), + (b"MM\x00*", "tiff"), +) + +# Formats the Anthropic API accepts directly; anything else has to be +# converted before it can be sent. +DIRECTLY_SUPPORTED = {"png", "jpeg", "gif", "webp"} + + +def sniff_format(image_bytes): + """Identify an image from its leading bytes. Returns a short name or None.""" + if not image_bytes: + return None + head = image_bytes[:32] + for signature, name in _SIGNATURES: + if head.startswith(signature): + return name + if head[:4] == b"RIFF" and head[8:12] == b"WEBP": + return "webp" + # HEIC/HEIF (iPhone's default) declares itself in an 'ftyp' box. + if head[4:8] == b"ftyp": + brand = head[8:12] + if brand in (b"heic", b"heix", b"hevc", b"hevx", b"mif1", b"msf1", b"heim"): + return "heic" + if brand in (b"avif", b"avis"): + return "avif" + return None + + +def normalize_upload(image_bytes, filename="upload"): + """Return (bytes, filename, error) with the image in a sendable format. + + Passes through anything the API already accepts. Anything else that + Pillow can open — HEIC with the right plugin, BMP, TIFF, AVIF — is + re-encoded as JPEG rather than refused, since the bytes are perfectly + good and only the container is wrong. + """ + fmt = sniff_format(image_bytes) + if fmt in DIRECTLY_SUPPORTED: + stem = filename.rsplit(".", 1)[0] if "." in filename else filename + return image_bytes, "{}.{}".format(stem or "upload", "jpg" if fmt == "jpeg" else fmt), None + + if Image is None: + return None, None, ( + "That file is {} and this app can only send PNG, JPEG, GIF or WebP. " + "Installing pillow (python3 -m pip install --user pillow) would let " + "it convert automatically.".format(fmt or "an unrecognised format")) + + try: + img = Image.open(io.BytesIO(image_bytes)) + img.load() + if img.mode not in ("RGB", "L"): + img = img.convert("RGB") + buffer = io.BytesIO() + img.save(buffer, format="JPEG", quality=92) + stem = filename.rsplit(".", 1)[0] if "." in filename else filename + return buffer.getvalue(), "{}.jpg".format(stem or "upload"), None + except Exception: + if fmt in ("heic", "avif"): + return None, None, ( + "That photo is {} format, which needs an extra decoder. Either " + "install it (python3 -m pip install --user pillow-heif) or set " + "your phone's camera to save JPEG instead of " + "High Efficiency.".format(fmt.upper())) + return None, None, ( + "Couldn't read that file — it doesn't look like a readable image.") + + +def _detect_card_box(img): + """Best-effort bounding box of the card within the photo. + + Works by estimating the background colour from the photo's own corners + and then finding the rows and columns that stop looking like background. + That beats edge-density detection here: a card's interior is full of + artwork and text, so edge density peaks in the middle and gives a box + that drifts several percent past the real cut line. For edge strips that + slop matters — it fills the strip with desk or mat instead of the card's + border, which is the whole thing being examined. + + Returns None when the result doesn't look like a card, so the caller can + fall back to treating the whole frame as the card. + """ + rgb = img.convert("RGB") + # Detect at a fairly high resolution. Downscaling harder is cheaper, but + # it blurs the outermost pixels of the card into the background — and on + # a pale background, a heavily whitened edge then reads AS background, so + # the boundary walks inward past the wear and the measurement misses the + # very thing it is looking for. Found exactly that way round in testing. + scale = 800.0 / max(rgb.size) + if scale < 1: + rgb = rgb.resize((max(1, int(rgb.width * scale)), + max(1, int(rgb.height * scale))), Image.BILINEAR) + else: + scale = 1.0 + + w, h = rgb.size + if w < 20 or h < 20: + return None + px = rgb.load() + + # Estimate the background from the four image corners. If the card fills + # the frame these samples are card, every pixel then reads as "not + # background", and the box correctly comes back as the whole image. + patch = max(2, min(w, h) // 25) + samples = [] + for cx, cy in ((0, 0), (w - patch, 0), (0, h - patch), (w - patch, h - patch)): + for x in range(cx, min(w, cx + patch)): + for y in range(cy, min(h, cy + patch)): + samples.append(px[x, y]) + bg = tuple(sum(c[i] for c in samples) // len(samples) for i in range(3)) + + def differs(p): + return abs(p[0] - bg[0]) + abs(p[1] - bg[1]) + abs(p[2] - bg[2]) > 90 + + row_counts = [0] * h + col_counts = [0] * w + for y in range(h): + for x in range(w): + if differs(px[x, y]): + row_counts[y] += 1 + col_counts[x] += 1 + + def span(counts, extent): + # A real card edge makes most of a row/column stop being background + # at once, so key off a share of the perpendicular extent rather than + # off the peak — that keeps a few stray specks of noise in the + # background from widening the box. + cutoff = extent * 0.35 + hits = [i for i, c in enumerate(counts) if c >= cutoff] + return (hits[0], hits[-1]) if hits else None + + rows, cols = span(row_counts, w), span(col_counts, h) + if not rows or not cols: + return None + + box = (int(cols[0] / scale), int(rows[0] / scale), + int(round(cols[1] / scale)), int(round(rows[1] / scale))) + box = (max(0, box[0]), max(0, box[1]), + min(img.width, box[2] + 1), min(img.height, box[3] + 1)) + + bw, bh = box[2] - box[0], box[3] - box[1] + if bw < 40 or bh < 40: + return None + # A tiny box means detection latched onto something that isn't the card. + if float(bw * bh) / float(img.width * img.height) < 0.15: + return None + return box + + +def _surface_map(piece): + """A band-pass view that isolates surface texture from the artwork. + + Scratches, print lines and dents are low-contrast marks sitting on + artwork that is far higher contrast than they are — which is exactly why + they vanish in a normal view. Subtracting a heavily blurred copy cancels + the smooth artwork; subtracting from a lightly blurred copy rather than + the raw pixels first drops the halftone dots, which otherwise dominate + the result on any printed card. What survives is the band where surface + damage lives, and stretching the contrast makes it legible. + + The output deliberately exaggerates: holo texture, foil patterns and JPEG + blocking all light up alongside real damage, so it is only ever shown + beside the untouched crop for comparison, never on its own. + """ + grey = piece.convert("L") + fine = grey.filter(ImageFilter.GaussianBlur(SURFACE_FINE_RADIUS)) + coarse = grey.filter(ImageFilter.GaussianBlur(SURFACE_COARSE_RADIUS)) + band = ImageChops.difference(fine, coarse) + return ImageOps.autocontrast(band, cutoff=SURFACE_AUTOCONTRAST_CUTOFF) + + +def _band_stats(luma_px, hsv_px, x, y0, y1): + """Mean luma and saturation down one column of a band. + + Luma rather than HSV's "value": V is max(R,G,B), which makes a saturated + yellow border and bare white paper both read as 255 — the exact case this + is trying to measure. Luma weights the channels the way brightness is + actually perceived, so yellow lands near 212 and white at 255, leaving a + real difference to detect. + """ + n = 0 + l_total = 0 + s_total = 0 + for y in range(y0, y1): + l_total += luma_px[x, y] + s_total += hsv_px[x, y][1] + n += 1 + if not n: + return None, None + return l_total / float(n), s_total / float(n) + + +def _column_reference(luma_px, hsv_px, x, y0, y1): + """Median luma/saturation down a column, plus how much it varies. + + Median rather than mean so a few pixels of text don't drag the baseline, + and the spread is returned so the caller can throw the column out + entirely when the reference clearly isn't uniform border. + """ + lumas = [] + sats = [] + for y in range(y0, y1): + lumas.append(luma_px[x, y]) + sats.append(hsv_px[x, y][1]) + if not lumas: + return None, None, None + lumas.sort() + sats.sort() + n = len(lumas) + spread = lumas[int(n * 0.9)] - lumas[int(n * 0.1)] + return lumas[n // 2], sats[n // 2], spread + + +def _measure_one_edge(card, geometry): + """Whitening measurement for the TOP edge of whatever is passed in. + + Each of the four edges is rotated to the top before calling this, so the + logic only ever has to handle one orientation. + + The measurement is a local, column-by-column comparison: the outermost + sliver of border is compared against the same border slightly deeper in, + at the same x. Whitening is the paper core showing through, so the outer + band goes lighter and loses saturation relative to the reference — while + a lighting gradient, a coloured border, or a dark card all affect both + bands together and cancel out. That self-referencing is the point: it + needs no idea what the card is supposed to look like. + """ + inset, band, gap = geometry + w, h = card.size + if h < inset + gap + band * 2 or w < 20: + return None + + luma = card.convert("L").load() + hsv = card.convert("HSV").load() + edge_y = (inset, inset + band) + + # Corners have their own category and their own rounding, so leave them + # out — otherwise every card's four rounded corners inflate every edge. + margin = max(2, int(w * 0.05)) + + # Read the outermost band once per column. Comparing against a band + # further into the card was tried first and cannot work generally: a + # Pokemon border is barely ten pixels deep, so any reference deep enough + # to be separate lands on the copyright line or the artwork, and a bright + # border measured against dark text reads as whitening down the entire + # edge. The baseline instead comes from the card's own border, worked out + # by the caller across all four edges at once. + # Also read a band just INSIDE the edge band, per column. Whitening is + # confined to the outermost millimetre or two at the cut, so it shows up + # as a step between these two bands. Glare, a glossy sleeve catching the + # light, or simply one side of the photo being brighter lifts BOTH bands + # together and produces no step — which is how the two get told apart. + inner_y = (edge_y[1] + max(1, band // 4), edge_y[1] + max(1, band // 4) + band) + if inner_y[1] > h: + inner_y = None + + columns = [] + for x in range(margin, w - margin): + l_edge, s_edge = _band_stats(luma, hsv, x, *edge_y) + if l_edge is None: + continue + if inner_y is None: + columns.append((l_edge, s_edge, None, None, None)) + continue + l_in, s_in, spread = _column_reference(luma, hsv, x, *inner_y) + columns.append((l_edge, s_edge, l_in, s_in, spread)) + if len(columns) < 20: + return None + return columns + + +def _score_edge(columns, base_l, base_s): + """Share of an edge's columns that read as whitened. + + A column has to clear two independent tests. The first compares it with + the card's own border pooled across all four edges, which catches wear + wherever it sits. The second requires an actual step between the + outermost band and the band just inside it, which is what makes it wear + rather than lighting: a bright reflection raises both bands equally and + fails this test, while paper showing through at the cut does not. + """ + whitened = 0 + deltas = [] + counted = 0 + for l_edge, s_edge, l_in, s_in, spread in columns: + d_light = l_edge - base_l + d_desat = base_s - s_edge + pooled = d_desat >= 55 or d_light >= 28 or (d_light >= 10 and d_desat >= 20) + + if l_in is None: + local = True # no inner band available; fall back to pooled only + elif spread is not None and spread > 55: + # The inner band landed on text or artwork, so it cannot confirm + # anything. Skip the column rather than guess — a false "clean" is + # cheaper here than a false accusation of wear. + continue + else: + local = (l_edge - l_in) >= 8 or (s_in - s_edge) >= 18 + + counted += 1 + if pooled and local: + whitened += 1 + deltas.append(max(d_light, d_desat * 0.4)) + + if counted < 20: + return None + return { + "percent": round(100.0 * whitened / counted, 1), + "mean_lift": round(sum(deltas) / float(len(deltas)), 1) if deltas else 0.0, + "columns_used": counted, + "baseline_luma": round(base_l, 1), + "baseline_saturation": round(base_s, 1), + } + + +def edge_wear_profile(image_bytes): + """Measure whitening along all four edges. Returns None if unavailable. + + Gives back, per edge, the share of its length that reads as whitened and + how strong the lift is — numbers a vision model cannot produce by eye, + and which are immune to the thing that kept defeating it: telling a + genuine pale band apart from the cut line and the border's own + anti-aliasing. + """ + if Image is None: + return None + try: + img = Image.open(io.BytesIO(image_bytes)) + img.load() + if img.mode not in ("RGB", "L"): + img = img.convert("RGB") + box = _detect_card_box(img) or (0, 0, img.width, img.height) + card = img.crop(box).convert("RGB") + + short = min(card.size) + geometry = ( + # Only just enough to clear the cut line and its anti-aliasing. + # Whitening starts AT the cut, so an inset chosen to be safe + # against background bleed instead steps straight over the thing + # being measured — that alone was halving the signal. + max(2, int(round(short * 0.004))), + max(4, int(round(short * 0.011))), # band thickness + max(8, int(round(short * 0.020))), # depth of the reference band + ) + + rotations = { + "top": 0, + "right": 90, # rotating 90 CCW brings the right edge to the top + "bottom": 180, + "left": 270, + } + collected = {} + for name, angle in rotations.items(): + face = card if angle == 0 else card.rotate(angle, expand=True) + collected[name] = _measure_one_edge(face, geometry) + if all(v is None for v in collected.values()): + return None + + # Baseline from ALL four edges pooled, not each edge against itself. + # Whitening only ever raises luma and lowers saturation, so unworn + # border sits at the low end of one and the high end of the other, + # and quartiles across the whole card find it. Scoring an edge + # against only its own length silently fails on the case that + # matters most — an edge worn evenly end to end, where the baseline + # becomes the wear and the damage cancels itself out. Pooling means + # three clean edges anchor the fourth. + pooled = [c for cols in collected.values() if cols for c in cols] + if len(pooled) < 40: + return None + lumas = sorted(c[0] for c in pooled) + sats = sorted(c[1] for c in pooled) + base_l = lumas[int(len(lumas) * 0.25)] + base_s = sats[int(len(sats) * 0.75)] + + # Decide whether this card can be measured at all before reporting a + # number for it. The method detects paper showing through a printed + # border, which presumes the border is darker and more saturated than + # bare card stock. Silver, white, foil and refractor borders break + # that presumption outright — they are already pale and colourless, + # so ordinary variation in them reads exactly like wear, and the + # result is a confident accusation against a clean card. Refusing to + # answer is the right outcome there; a wrong number is worse than no + # number, because it drags the whole grade down with it. + iqr = lumas[int(len(lumas) * 0.75)] - lumas[int(len(lumas) * 0.25)] + reason = None + if base_l >= 205 and base_s <= 45: + reason = ("the card's border is white, silver or foil, where paper " + "showing through looks the same as the border itself") + elif iqr >= 70: + reason = ("the border's brightness varies too much across the card " + "— typical of a refractor or prismatic finish — for a " + "whitening measurement to mean anything") + + edges = {name: (_score_edge(cols, base_l, base_s) if cols else None) + for name, cols in collected.items()} + return { + "edges": edges, + "reliable": reason is None, + "reason": reason, + "border_luma": round(base_l, 1), + "border_saturation": round(base_s, 1), + } + except Exception: + return None + + +def _border_width(px, size, side, border_rgb, skip=0, tolerance=70): + """How far the uniform border reaches in from one side, in pixels. + + Sampled along several lines and taken as the median, so a logo or a bit + of artwork touching the border on one line doesn't decide the answer. + Corners are avoided — their rounding would read as a wider border. + + `skip` steps over the cut line and its anti-aliasing before measuring; + without it the very first pixel is still background and every side reads + as a zero-width border. + """ + w, h = size + along = h if side in ("left", "right") else w + depth_limit = int((w if side in ("left", "right") else h) * 0.30) + if depth_limit < 3: + return None + + def matches(p): + return (abs(p[0] - border_rgb[0]) + abs(p[1] - border_rgb[1]) + + abs(p[2] - border_rgb[2])) <= tolerance + + # Walk inward one row at a time asking how much of that row is still + # border, rather than stopping at the first pixel that isn't. Text + # printed inside the border — a vintage nameplate, a modern copyright + # line, the collector number — otherwise halts the scan almost at the cut + # and reports a border a fraction of its real width. Those characters are + # thin, so the row they sit on is still mostly border; the design proper + # takes the whole row at once, which is the transition being looked for. + positions = [int(along * (0.2 + 0.6 * i / 24.0)) for i in range(25)] + positions = [p for p in positions if 0 <= p < along] + if not positions: + return None + + for depth in range(skip, depth_limit): + hits = 0 + for pos in positions: + if side == "left": + p = px[depth, pos] + elif side == "right": + p = px[w - 1 - depth, pos] + elif side == "top": + p = px[pos, depth] + else: + p = px[pos, h - 1 - depth] + if matches(p): + hits += 1 + if hits < len(positions) * 0.5: + return depth + return depth_limit + + +def centering_profile(image_bytes): + """Measure how well centred the card's design is inside its border. + + Centering is the one PSA category that is purely geometric — it is a + ratio of border widths, with published tolerances attached — so it can + be measured outright rather than estimated. Returns the widths, the + left/right and top/bottom ratios, and the worse of the two, which is + what a grader keys off. + + Returns None when the card has no uniform border to measure against + (full-bleed modern cards) or the read looks implausible, so the caller + can fall back to the model's own eye rather than trust a bad number. + """ + if Image is None: + return None + try: + img = Image.open(io.BytesIO(image_bytes)) + img.load() + card = img.crop(_detect_card_box(img) or (0, 0, img.width, img.height)) + card = card.convert("RGB") + w, h = card.size + if w < 60 or h < 60: + return None + px = card.load() + + # The border colour, sampled just inside the cut at the midpoint of + # each side — far from corners and from any design element. + inset = max(2, int(min(w, h) * 0.012)) + samples = [px[inset, h // 2], px[w - 1 - inset, h // 2], + px[w // 2, inset], px[w // 2, h - 1 - inset]] + border_rgb = tuple(sorted(c[i] for c in samples)[len(samples) // 2] + for i in range(3)) + + widths = {side: _border_width(px, (w, h), side, border_rgb, skip=inset) + for side in ("left", "right", "top", "bottom")} + if any(v is None for v in widths.values()): + return None + + # A border that vanishes, or that swallows a third of the card, means + # this isn't a bordered card or the sample missed — either way the + # ratio would be meaningless. + if min(widths.values()) < 2: + return None + if max(widths["left"], widths["right"]) > w * 0.28: + return None + if max(widths["top"], widths["bottom"]) > h * 0.28: + return None + + def ratio(a, b): + total = float(a + b) + if total <= 0: + return None + bigger = 100.0 * max(a, b) / total + return round(bigger, 1) + + horizontal = ratio(widths["left"], widths["right"]) + vertical = ratio(widths["top"], widths["bottom"]) + if horizontal is None or vertical is None: + return None + + return { + "widths_px": widths, + "horizontal": horizontal, + "vertical": vertical, + "worst": round(max(horizontal, vertical), 1), + "horizontal_label": "{:.0f}/{:.0f}".format(horizontal, 100 - horizontal), + "vertical_label": "{:.0f}/{:.0f}".format(vertical, 100 - vertical), + "wider_side": ("left" if widths["left"] > widths["right"] else "right", + "top" if widths["top"] > widths["bottom"] else "bottom"), + } + except Exception: + return None + + +def _edge_enhanced(strip): + """Whitening map of an edge strip, keyed on colour saturation. + + Edge whitening is physically the paper core showing through a printed + border, so its signature is a loss of saturation rather than a change in + brightness. Mapping saturation and inverting it therefore isolates + exactly the thing being looked for: worn paper lights up, printed colour + goes dark, whatever the border's colour happens to be. + + Brightness-based contrast stretching was tried first and is actively + misleading here — on a light border (a yellow Pokemon frame, a white + 1980s border) it pushes the border itself to near-white and buries the + very wear it was meant to reveal. + + The limit worth knowing: on an already-unsaturated white border there is + no saturation left to lose, so this map stays flat and the untouched + strip beside it has to carry the judgement. + """ + saturation = strip.convert("HSV").split()[1] + return ImageOps.autocontrast( + ImageOps.invert(saturation), cutoff=1).convert("RGB") + + +def _stack(top, bottom, gap=10): + top = top.convert("RGB") + bottom = bottom.convert("RGB") + w = max(top.width, bottom.width) + canvas = Image.new("RGB", (w, top.height + gap + bottom.height), (18, 18, 18)) + canvas.paste(top, (0, 0)) + canvas.paste(bottom, (0, top.height + gap)) + return canvas + + +def _side_by_side(left, right, gap=14): + """Untouched crop beside its surface map, so one can check the other.""" + left = left.convert("RGB") + right = right.convert("RGB") + h = max(left.height, right.height) + canvas = Image.new("RGB", (left.width + gap + right.width, h), (18, 18, 18)) + canvas.paste(left, (0, 0)) + canvas.paste(right, (left.width + gap, 0)) + return canvas + + +def _encode(img): + buffer = io.BytesIO() + img.save(buffer, format="JPEG", quality=92) + return buffer.getvalue() + + +def _resized(piece, factor): + if factor > 1: + piece = piece.resize( + (max(1, int(piece.width * factor)), max(1, int(piece.height * factor))), + Image.LANCZOS, + ) + return piece if piece.mode == "RGB" else piece.convert("RGB") + + +def _magnify(piece, target_px): + """Scale a roughly square crop so its long edge hits target_px.""" + return _resized(piece, target_px / float(max(piece.size))) + + +def _magnify_strip(piece): + """Scale a long thin strip by its short axis, capping the long one. + + Targeting the long axis here would be a no-op — it's already large — and + would leave the across-the-strip detail, which is the part that actually + shows whitening, at whatever the source happened to give. + """ + factor = EDGE_SHORT_TARGET_PX / float(min(piece.size)) + factor = min(factor, EDGE_LONG_CAP_PX / float(max(piece.size))) + return _resized(piece, factor) + + +def detail_crops(image_bytes, filename="card.jpg", corners=True, edges=True, + surface=True): + """Magnified crops of a card's corners and edge strips, in that order. + + Returns [(bytes, filename), ...]. Empty when Pillow is missing, the image + is too small to zoom into, or anything goes wrong — grading then proceeds + on the full photo alone, which is the pre-Pillow behaviour. + + Corners and edges are cropped separately rather than relying on the + corner crops alone: a corner crop only covers the ends of each side, so + whitening running along the middle of an edge falls between them. + """ + if Image is None: + return [] + try: + img = Image.open(io.BytesIO(image_bytes)) + img.load() + if img.mode not in ("RGB", "L"): + img = img.convert("RGB") + + if max(img.size) < MIN_SOURCE_PX: + return [] + + box = _detect_card_box(img) or (0, 0, img.width, img.height) + card = img.crop(box) + w, h = card.width, card.height + stem = filename.rsplit(".", 1)[0] + crops = [] + + if corners: + cw = max(1, int(w * CORNER_FRACTION)) + ch = max(1, int(h * CORNER_FRACTION)) + regions = { + "top-left": (0, 0, cw, ch), + "top-right": (w - cw, 0, w, ch), + "bottom-left": (0, h - ch, cw, h), + "bottom-right": (w - cw, h - ch, w, h), + } + for name in CORNERS: + piece = card.crop(regions[name]) + if min(piece.size) < 8: + continue + crops.append((_encode(_magnify(piece, CORNER_TARGET_PX)), + "{}-{}.jpg".format(stem, name))) + + if edges: + # Pull in a couple of pixels first. Box detection lands within + # about two pixels of the cut, and any background left in the + # strip is a problem specifically for the saturation map: an + # unsaturated backdrop (a dark mat, a white desk) reads as bright + # there, sitting exactly where whitening would be and faking it + # on a perfectly clean card. Costs a sliver of the real edge, + # which is worth it to kill a false positive. + inset = max(4, int(round(min(w, h) * 0.012))) + face = card.crop((inset, inset, max(inset + 1, w - inset), + max(inset + 1, h - inset))) + fw, fh = face.size + ew = max(1, int(fw * EDGE_FRACTION)) + eh = max(1, int(fh * EDGE_FRACTION)) + regions = { + "top-edge": (0, 0, fw, eh), + "right-edge": (fw - ew, 0, fw, fh), + "bottom-edge": (0, fh - eh, fw, fh), + "left-edge": (0, 0, ew, fh), + } + card_for_edges = face + for name in EDGES: + piece = card_for_edges.crop(regions[name]) + if min(piece.size) < 8: + continue + # Deliberately the plain strip, with no processed companion. + # A saturation map was tried here (worn paper is desaturated, + # so in principle whitening should light up) and measurably + # backfired: every card, clean ones included, then came back + # "minor whitening", with the location moving between runs. + # The cut line itself and the border's own anti-aliasing + # produce a signal indistinguishable from light wear, so the + # map added noise the model anchored on rather than evidence. + # Surface keeps its processed companion because there the + # signal is genuinely separable; edges do better without one. + crops.append((_encode(_magnify_strip(piece)), + "{}-{}.jpg".format(stem, name))) + + if surface: + rows, cols = SURFACE_TILES + index = 0 + for ry in range(rows): + for cx in range(cols): + piece = card.crop(( + int(w * cx / cols), int(h * ry / rows), + int(w * (cx + 1) / cols), int(h * (ry + 1) / rows), + )) + if min(piece.size) < 16: + index += 1 + continue + combo = _side_by_side(piece, _surface_map(piece)) + name = SURFACE_QUADRANTS[index] if index < len(SURFACE_QUADRANTS) \ + else "region{}".format(index) + crops.append((_encode(_magnify(combo, SURFACE_TILE_TARGET_PX)), + "{}-surface-{}.jpg".format(stem, name))) + index += 1 + + return crops + except Exception: + # A grading run is worth more than a perfect crop — never let an + # image-processing failure take out the whole request. + return [] + + +def corner_crops(image_bytes, filename="card.jpg"): + """Corner close-ups only. Kept for callers that don't want edge strips.""" + return detail_crops(image_bytes, filename, corners=True, edges=False) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9a1fc5d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +services: + card-grader: + build: . + container_name: card-grader + restart: unless-stopped + # Bound to 127.0.0.1 deliberately: nginx (running on the Unraid host, + # or in its own container sharing the host network) is the only thing + # that should reach this port directly. Nothing on the LAN or internet + # can hit :8778 without going through nginx's TLS termination. + ports: + - "127.0.0.1:8778:8778" + volumes: + - ./data:/data + environment: + # Refuses settings writes from anyone but you, so a visitor can't + # overwrite your API key or switch to a pricier model. Set your key + # via the container's own console the first time (see README), then + # leave this on. + - CARD_GRADER_LOCK=1 diff --git a/nginx/card-grader.conf b/nginx/card-grader.conf new file mode 100644 index 0000000..b1b4f1b --- /dev/null +++ b/nginx/card-grader.conf @@ -0,0 +1,43 @@ +# Reverse proxy for Card Grader. Replace card-grader.example.com below with +# your actual subdomain, point an A record at your Unraid box's public IP +# (or use a dynamic-DNS hostname if your ISP doesn't give you a static one), +# and forward ports 80 + 443 on your router to the Unraid box. + +server { + listen 80; + server_name card-grader.example.com; + + # certbot's webroot plugin needs this path reachable over plain HTTP to + # issue/renew the certificate; everything else redirects to HTTPS. + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + http2 on; + server_name card-grader.example.com; + + ssl_certificate /etc/letsencrypt/live/card-grader.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/card-grader.example.com/privkey.pem; + + # Grading uploads can be a few photos at once; app.py caps the combined + # decoded size at 12MB, and base64 inflates that by ~33% on the wire — + # give nginx enough headroom that it isn't the thing rejecting uploads. + client_max_body_size 20m; + + location / { + proxy_pass http://127.0.0.1:8778; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + # Grading calls out to Anthropic and can take a while on Opus/careful + # effort; don't let nginx's default 60s read timeout cut it off. + proxy_read_timeout 120s; + } +} diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..de5d785 --- /dev/null +++ b/static/app.js @@ -0,0 +1,572 @@ +/* Card Grader — UI */ + +const state = { settings: {}, modelGuide: {}, history: [] }; + +const $ = (sel) => document.querySelector(sel); + +function esc(text) { + return String(text === null || text === undefined ? '' : text) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"'); +} + +/* ------------------------------------------------------------------ api */ + +// The path this app is mounted under (e.g. "/cards"), or "" at the domain +// root — set server-side via a data attribute since a script has no other +// reliable way to know where it was served from. +const APP_BASE = document.documentElement.dataset.base || ''; + +async function api(path, options = {}) { + const response = await fetch(APP_BASE + path, { + headers: { 'Content-Type': 'application/json' }, + ...options, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + const text = await response.text(); + let data = null; + try { data = text ? JSON.parse(text) : null; } catch (_) { data = null; } + if (!response.ok) { + throw new Error((data && data.error) || `Request failed (${response.status})`); + } + return data; +} + +function banner(message, isError = false) { + const node = $('#banner'); + if (!message) { node.hidden = true; return; } + node.className = isError ? 'banner err' : 'banner'; + node.textContent = message; + node.hidden = false; + if (!isError) setTimeout(() => { node.hidden = true; }, 3200); +} + +/* ------------------------------------------------------------- file read */ +// +// Read straight to a data URL at pick time, and only clear the input once +// the bytes are safely in hand. Holding File objects and reading them later +// looks equivalent and isn't: clearing input.value (which is what lets you +// re-pick the same file) can detach the underlying blob in some browsers, so +// a read attempted afterward fails with no useful reason. Reading now +// sidesteps that, and the data URL doubles as the preview source. + +function bytesToBase64(bytes) { + // Chunked: fromCharCode.apply on a multi-megabyte array blows the stack. + let binary = ''; + const chunk = 0x8000; + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk)); + } + return btoa(binary); +} + +async function readOneImage(file) { + // Two independent read paths, because they fail independently. FileReader + // is the older API and is the one that trips over files a phone exposes + // through a cloud provider or a scoped-storage URI; file.arrayBuffer() is + // the modern path and frequently succeeds where it doesn't. Trying both + // turns a hard failure into a retry, and if both fail the real + // DOMException name gets reported rather than a guess at the cause. + const errors = []; + try { + const buffer = await file.arrayBuffer(); + if (buffer && buffer.byteLength) { + const type = file.type || 'image/jpeg'; + return `data:${type};base64,${bytesToBase64(new Uint8Array(buffer))}`; + } + errors.push('arrayBuffer returned nothing'); + } catch (err) { + errors.push(`arrayBuffer: ${err && err.name ? err.name : err}`); + } + + try { + return await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result)); + reader.onerror = () => reject(reader.error || new Error('unknown')); + reader.readAsDataURL(file); + }); + } catch (err) { + errors.push(`FileReader: ${err && err.name ? err.name : err}`); + } + + const detail = errors.join('; '); + const hint = /NotReadable|NotFound/i.test(detail) + ? ' The file may be stored in the cloud rather than on the device — open it once in your photo app so it downloads, then try again.' + : ' Try saving a copy of it first, then pick the copy.'; + throw new Error(`Could not read "${file.name || 'that file'}" (${detail}).${hint}`); +} + +async function readPickedImages(input, limit) { + const picked = Array.from(input.files || []).slice(0, Math.max(0, limit)); + const out = []; + for (const file of picked) { + out.push({ name: file.name || 'upload', dataUrl: await readOneImage(file) }); + } + input.value = ''; + return out; +} + +function pickedToPayload(items) { + return items.map((i) => ({ filename: i.name, image_base64: i.dataUrl.split(',')[1] })); +} + +/* --------------------------------------------------------------- grading */ + +const SEVERITY_PILL = { + none: 'pill-grade', minor: 'pill-marginal', moderate: 'pill-marginal', + major: 'pill-critical', cannot_assess: 'pill-raw', +}; +const SEVERITY_LABEL = { + none: 'clean', minor: 'minor', moderate: 'moderate', major: 'major', + cannot_assess: "can't tell from photo", +}; + +const gradeState = { files: [], result: null, busy: false }; + +function renderGradeBlock(g) { + if (!g) return ''; + const m = g.edge_measurements || null; + let measuredLine = ''; + if (m && m.reliable === false) { + measuredLine = `not measurable — ${m.reason || "this card's finish"}`; + } else if (m && m.edges) { + measuredLine = ['top', 'right', 'bottom', 'left'] + .filter((s) => m.edges[s]) + .map((s) => `${s} ${m.edges[s].percent.toFixed(0)}%`).join(' · '); + } + + const cm = g.centering_measurement || null; + const centeringLine = cm + ? `measured — left/right ${cm.horizontal_label} · top/bottom ${cm.vertical_label}` : ''; + + const rows = ['centering', 'corners', 'edges', 'surface'].map((key) => { + const cat = (g.categories || {})[key] || {}; + const sev = cat.severity || 'cannot_assess'; + let extra = ''; + if (key === 'edges' && measuredLine) { + extra = `
measured whitening — ${esc(measuredLine)}
`; + } else if (key === 'centering' && centeringLine) { + extra = `
${esc(centeringLine)}
`; + } + return ` + ${key} + ${esc(SEVERITY_LABEL[sev])} + ${esc(cat.observation || '')}${extra} + `; + }).join(''); + + return ` +
A photo can't show surface scratches, print lines, or light edge wear the + way a grader's raking light does — and a seller's listing photo is often lit to hide them. + Treat this as a rough screen, not a prediction of what it comes back as.
+
${renderSlab(g)}
+
${rows}
+ ${(g.limitations || []).length ? `
Couldn't check: +
` : ''} + ${g.note ? `

${esc(g.note)}

` : ''}`; +} + +const GRADE_WORD = { + 10: 'Gem Mint', 9: 'Mint', 8: 'NM-MT', 7: 'Near Mint', 6: 'EX-MT', + 5: 'Excellent', 4: 'VG-EX', 3: 'Very Good', 2: 'Good', 1: 'Poor', +}; +const TYPE_LABEL = { + pokemon: 'Pokémon', sports: 'Sports', other_tcg: 'TCG', other: 'Card', +}; + +function slabClass(grade) { + if (grade === null || grade === undefined) return 'slab-low'; + if (grade >= 10) return 'slab-10'; + if (grade === 9) return 'slab-9'; + if (grade >= 7) return 'slab-8'; + if (grade >= 5) return 'slab-6'; + return 'slab-low'; +} + +function renderSlab(g, opts = {}) { + const grade = g.estimated_grade; + const range = (g.grade_low !== null && g.grade_high !== null && g.grade_low !== g.grade_high) + ? `realistically ${g.grade_low}–${g.grade_high}` : ''; + const title = opts.title || g.card_note || 'Card'; + const bits = [range, `${g.confidence || ''} confidence`].filter(Boolean).join(' · '); + return ` +
+
+ ${g.card_type ? `
${esc(TYPE_LABEL[g.card_type] || 'Card')}
` : ''} +
${esc(title)}
+
${esc(bits)}
+
+
+ ${grade === null || grade === undefined ? '—' : grade} + ${esc(GRADE_WORD[grade] || 'estimate')} +
+
`; +} + +function renderGradeReview() { + const el = $('#grade-review'); + if (!gradeState.files.length && !gradeState.result) { el.innerHTML = ''; return; } + + if (!gradeState.result) { + el.innerHTML = ` +
+
+

${gradeState.files.length} photo(s) ready

+ more angles = a tighter estimate +
+
+ ${gradeState.files.map((f) => + ``).join('')} +
+ + + +
+
+
`; + return; + } + + const g = gradeState.result.grade; + const cost = gradeState.result.grade.estimated_cost; + el.innerHTML = ` +
+
+

Grade estimate

+ ${cost ? `about $${cost.toFixed(3)}` : ''} +
+ ${renderGradeBlock(g)} +
+ +
+
`; +} + +async function runGrade() { + gradeState.busy = true; + renderGradeReview(); + const status = $('#grade-status'); + status.hidden = false; + status.textContent = `Reading ${gradeState.files.length} photo(s)… this takes a few seconds and costs a few cents.`; + try { + const body = { + images: pickedToPayload(gradeState.files), + label: ($('#grade-label').value || '').trim() || null, + }; + const key = myApiKey(); + if (key) body.api_key = key; + gradeState.result = await api('/api/grade', { method: 'POST', body }); + status.hidden = true; + $('#grade-label').value = ''; + loadHistory().catch(() => {}); + } catch (err) { + status.textContent = `Grading failed: ${err.message}`; + } finally { + gradeState.busy = false; + renderGradeReview(); + } +} + +function resetGradeState() { + gradeState.files = []; + gradeState.result = null; + gradeState.busy = false; + $('#grade-status').hidden = true; + renderGradeReview(); +} + +$('#btn-grade').addEventListener('click', () => { + resetGradeState(); + $('#grade-file').click(); +}); +$('#grade-file').addEventListener('change', async (e) => { + try { + const picked = await readPickedImages(e.target, 6 - gradeState.files.length); + if (!picked.length) return; + gradeState.files.push(...picked); + renderGradeReview(); + } catch (err) { + $('#grade-status').hidden = false; + $('#grade-status').textContent = err.message; + } +}); +$('#grade-review').addEventListener('click', (e) => { + if (e.target.closest('#grade-add-more')) { $('#grade-file').click(); return; } + if (e.target.closest('#grade-cancel') || e.target.closest('#grade-discard')) { + resetGradeState(); return; + } + if (e.target.closest('#grade-run')) { + if (!gradeState.busy) runGrade(); + } +}); + +/* --------------------------------------------------------------- history */ + +function gradePillClass(grade) { + if (grade === null || grade === undefined) return 'pill-raw'; + if (grade >= 8) return 'pill-grade'; + if (grade >= 5) return 'pill-marginal'; + return 'pill-critical'; +} + +function fmtWhen(iso) { + if (!iso) return ''; + return String(iso).replace('T', ' ').slice(0, 16); +} + +async function loadHistory() { + const data = await api('/api/history'); + state.history = data.grades; + renderHistory(); +} + +function renderHistory() { + const rows = state.history; + $('#history-count').textContent = rows.length ? `${rows.length} graded` : ''; + $('#history-empty').hidden = rows.length > 0; + $('#history-body').innerHTML = rows.map((g) => ` + + +
+ ${g.thumbnail ? `` : ''} +
+
${esc(g.label || g.card_note || 'Untitled card')}
+
${g.card_type ? `${esc(TYPE_LABEL[g.card_type] || 'Card')} · ` : ''}${g.image_count || 1} photo(s)
+
+
+ + ${ + g.estimated_grade === null ? 'n/a' : `PSA ${g.estimated_grade}`} + ${esc(g.confidence || '')} + ${esc(fmtWhen(g.created_at))} + + `).join(''); +} + +function renderEntryModal(g) { + $('#entry-modal').innerHTML = ` +
+

${esc(g.label || g.card_note || 'Untitled card')}

+ +
+
${esc(fmtWhen(g.created_at))} · + ${g.usage && g.usage.model ? esc(g.usage.model) : ''}
+ ${renderGradeBlock(g)} +
+ + + + +
`; + $('#entry-modal').hidden = false; + $('#entry-scrim').hidden = false; +} + +function closeEntryModal() { + $('#entry-modal').hidden = true; + $('#entry-scrim').hidden = true; +} + +$('#history-body').addEventListener('click', async (e) => { + const del = e.target.closest('[data-history-delete]'); + if (del) { + e.stopPropagation(); + await api(`/api/history/${del.dataset.historyDelete}`, { method: 'DELETE' }); + await loadHistory(); + return; + } + const row = e.target.closest('[data-history-row]'); + if (row) { + try { + const data = await api(`/api/history/${row.dataset.historyRow}`); + renderEntryModal(data.grade); + } catch (err) { + banner(err.message, true); + } + } +}); + +$('#entry-modal').addEventListener('click', async (e) => { + if (e.target.closest('#entry-close')) return closeEntryModal(); + const del = e.target.closest('[data-history-delete]'); + if (del) { + await api(`/api/history/${del.dataset.historyDelete}`, { method: 'DELETE' }); + closeEntryModal(); + await loadHistory(); + return; + } + if (e.target.closest('#entry-save-label')) { + const id = $('#entry-modal').querySelector('[data-history-delete]').dataset.historyDelete; + const label = $('#entry-label').value.trim(); + await api(`/api/history/${id}`, { method: 'PATCH', body: { label } }); + closeEntryModal(); + await loadHistory(); + banner('Renamed.'); + } +}); +$('#entry-scrim').addEventListener('click', closeEntryModal); + +/* -------------------------------------------------------------- settings */ + +async function openSettings() { + const s = state.settings; + try { state.modelGuide = await api('/api/vision-models'); } catch (_) { state.modelGuide = {}; } + + const modelOptions = Object.entries(state.modelGuide).map(([id, info]) => + ``).join(''); + + const locked = s.settings_locked; + + $('#settings-modal').innerHTML = ` +
+

Settings

+ +
+ +
+

Your API key

+
+
+ + + Stored only in this browser and sent with your own grading + requests — never saved on the server. Get one at console.anthropic.com. + ${s.server_key_configured + ? 'This server already has a key set up, so you can leave this blank and use that instead — but then the owner pays for your grades.' + : 'This server has no key of its own, so you need one here to grade anything.'} +
+
+
+ +
+

Server settings${locked ? ' locked' : ''}

+ ${locked ? `
This server is shared, so its settings are read-only. + Use your own key above.
` : ` +
+
+ + + Never sent back to the browser once saved. Leave blank to keep + the current one. +
+
+ + + Sonnet 5 is the default for a tested reason — run head-to-head + against Haiku on the same cards, Haiku misread a PSA centering tolerance and landed + three grades off. Drop to Haiku only if cost matters more than accuracy to you. +
+
`} +
+ +
+ +
`; + $('#settings-modal').hidden = false; + $('#modal-scrim').hidden = false; +} + +/* A personal key lives in localStorage, never on the server — that's what + lets someone use a shared instance without spending the owner's credits. */ +function myApiKey() { + try { return localStorage.getItem('cardgrader_api_key') || ''; } catch (_) { return ''; } +} +function setMyApiKey(value) { + try { + if (value) localStorage.setItem('cardgrader_api_key', value); + else localStorage.removeItem('cardgrader_api_key'); + } catch (_) { /* private browsing — the field just won't persist */ } +} + +function closeSettings() { + $('#settings-modal').hidden = true; + $('#modal-scrim').hidden = true; +} + +async function saveSettings() { + setMyApiKey($('#set-my-key').value.trim()); + + // Only push server settings when this instance allows it, and only send a + // key when one was actually typed — an empty box means "leave it alone", + // not "erase it". + const serverKeyField = $('#set-api-key'); + if (serverKeyField) { + const payload = { vision_model: $('#set-model').value }; + const typed = serverKeyField.value.trim(); + if (typed) payload.anthropic_api_key = typed; + state.settings = await api('/api/settings', { method: 'POST', body: payload }); + } + closeSettings(); + updateModelCostHint(); + banner('Settings saved.'); +} + +function updateModelCostHint() { + const info = state.modelGuide[state.settings.vision_model]; + $('#model-cost-hint').textContent = info ? `~$${info.per_grade.toFixed(3)} per grade (${info.label})` : ''; +} + +$('#btn-settings').addEventListener('click', () => openSettings().catch((err) => banner(err.message, true))); +$('#settings-modal').addEventListener('click', (e) => { + if (e.target.closest('#settings-close')) return closeSettings(); + if (e.target.closest('#settings-save')) saveSettings().catch((err) => banner(err.message, true)); +}); +$('#modal-scrim').addEventListener('click', closeSettings); +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { closeSettings(); closeEntryModal(); } +}); + +/* ---------------------------------------------------------------- load */ + +async function load() { + state.settings = await api('/api/settings'); + try { state.modelGuide = await api('/api/vision-models'); } catch (_) { state.modelGuide = {}; } + updateModelCostHint(); + await loadHistory(); + if (!state.settings.server_key_configured && !myApiKey()) { + banner('Add your Anthropic API key in Settings before grading a card.'); + } +} + +load().catch((err) => banner(`Could not load: ${err.message}`, true)); + +/* ------------------------------------------------------------------ pwa */ +// +// Service workers only run in a secure context: https, or localhost. Over a +// plain LAN address (http://192.168.x.x) registration silently fails and the +// app stays a normal web page — which is exactly why the README pushes you +// through a tunnel rather than just handing friends your LAN IP. + +if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register(APP_BASE + '/sw.js').catch(() => { + /* Not fatal — the app works fine uninstalled. */ + }); + }); +} + +// Android/Chrome fires this instead of showing its own prompt, so the offer +// has to be surfaced deliberately. iOS never fires it: Safari has no +// programmatic install, only the manual Share > Add to Home Screen, so the +// hint below covers that case instead of pretending a button exists. +let deferredInstall = null; +window.addEventListener('beforeinstallprompt', (e) => { + e.preventDefault(); + deferredInstall = e; + const btn = $('#btn-install'); + if (btn) btn.hidden = false; +}); + +document.addEventListener('click', async (e) => { + if (!e.target.closest('#btn-install')) return; + if (!deferredInstall) return; + deferredInstall.prompt(); + await deferredInstall.userChoice; + deferredInstall = null; + $('#btn-install').hidden = true; +}); diff --git a/static/icon-180.png b/static/icon-180.png new file mode 100644 index 0000000000000000000000000000000000000000..050b7dc6f1324b7ff3f09e9d6752bb5a9061d76a GIT binary patch literal 981 zcmeAS@N?(olHy`uVBq!ia0vp^TR@nD2}o{QKQWbofq9Oni(^Q|oVRxj^F%x)+8!D? z*)eGf2}&08={Yo6FuUJ0RuIYyPFYgSyNEX|YOT%Z%IaIW!C$X`b_q+r`v3OTrPofB z=ijT%DLA`&b-1I9jhl?iBLyM$i54A4998&~3I#fwJ;)QV@Q_g`=ACx3oMOW(Wu z*bS#;mc?7H)%~xy@3rIqm(^zzF3f#=a9f~S zx4V#J!Doq&XEr)XzDp@fXr;^}l{?_H~tEe<6BW;5cK|MWZ7hLu$7rAMri%@lEQSXLrniwBO}vihX~p z?k+yI|Hwx7_ys(LZ!TWfaF#KDeR_&w-|a6`PYE37t$OJ%72cUVIqvV|W1){M&a4cR zd~{=pe%X~3GRHTzH}A`mWPNP$E&YD&L!(tk3*VgI_xo1rl&;4&=GT6IdU}Ph`>dz? z?8N>o2{h|Fo?iKS$#o#pxKDSpO^v#RZh%{>h1t2xWcfa+rH?E&@TZFOJf2`T1*lg2 z+rpklg>T+DOL`yKxR`&wiCEX;Wp*`}BJ}!_k5@)*?b%c)V|wNCq12GR$1fM#u8np3 zXfe;|!R1e>SNfCh&zQQ=OJ+M?v}E?t#3#Sk2pzv_w=E{}rcVI8bwOhWT_wdoee}C4lePwgwR(p5yBKFmPUd8qP zXW*@4FR$LP?&7xIYq##sE|0^x z{IBuM=E*$6cQccxRfpG=E`Bc)x_!du8<9R@?E3j%u5;B-pgiG2a?AqNdS;jX-`4!M Ru>fUY22WQ%mvv4FO#so^#2x?u literal 0 HcmV?d00001 diff --git a/static/icon-192.png b/static/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..c8375f612e1b6841136c2245bc4484060f3c7ee8 GIT binary patch literal 1045 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE2}s`E_d9@rf%%T7i(^Q|oVRxj^MrjRjz2uB z{zUnL+oDB>pFdzS=`b!3IoFV}Ys$>OZ+GuL z`s>}-+26TyzTSVfpM`OvkboegpddAgiQ%Uk-tDiAQY-!Oqg3+L-3a?lmW5W$X(m1n zu9qbB78|~6Ai;pKcGcFp~$&*cdXac6!#y~`rW`Qp{J zbZyDKij1+>tK8(B%~cj?N1fMB{{Qp1kq%?*>8J&AAaXH~JZkmH_UuxYFG=4X_scVG z-9Bw+g)HZarCTFxnX)q1hW9tjiuAswyuj?*s;?X?8n<3@{+-OUYvSLY$LZiAZc(DJ@1}VEt@%oJQ5*NDn^ln{mSoe&7#oOBvdU0Es zvVNOQtIA^ia^>T_YZHKsE8jwOzFy&4@pg5@tCgYww~cJJOy_>F$n(Og{@SY_jT0Ss zrHejlNd-)9yAo`*V1wea}>pmlMp{?yo@ye6TMH|-V{%e?JzT`v^qp8iS zSDHZO$?NA`Vr*qy@q9@XW9~Z#TYpXQ3vE(YKKFDnW-eN|h;i=y30LNYn=hDL_{RQ! z#gC;`|5lbb{P%wH{`q6}uX7exIru+aG|!mx1^><*H8P_MsBMvh!)|{2=~ZRQYj)jl z;FI6K@5}xR6P?%ZzL@WzJg<-S`t4GOyYtG-%U)E-$LTJfJ7xdA{jtloNvC|1xx2US zbKT3^f4BSh@1J9|`0mP*DmnXPmM-}%aJcGrF|nnjjVI$* s58vY@#sv;vl6$HtOlnxYJncyS>U$gu3??%d1G77Wr>mdKI;Vst03HODw*UYD literal 0 HcmV?d00001 diff --git a/static/icon-512-maskable.png b/static/icon-512-maskable.png new file mode 100644 index 0000000000000000000000000000000000000000..e71828221797f408d85a416dd53f0d72f398cf8e GIT binary patch literal 2733 zcmeH}{Wp|p7{{+?W@1Lh%V=u|O~y-wP}v=(H9}rii)vSdX0(aPOXDc3lzHd{HL(sG zN}kH1J)4?R>o^r-u~?!ydnPp2NG~))mb^{O>@&N6!v50k>8I~~?)y5|eVy<3+@Evj zh6MRh_004DfEwWMvjc#DF9|@_O1hbM4S?RE0G};irIn2hu=C8nhSS?uu0Ahc)={uA zEc-LhtT19=G_{>_FH=;MXta7$yQQ*p{7v~$S;U~*74Evx-I`rdQMb8`N8YPHfzP=X z#}P312DnNB#up#ue{Dg7hl)`0q;6G)yNWg^<8stT-`O~qK0WwH14m_6a^f+TLl>U} z!KG2GNMBG2g4|IKVk&oQAiWBgYRt=+J;qSJ-?h7iKWUwh&XwI_x#XWn>#_rNn+m%) z_0k1_92$5er-#Xl=KWzVKWaoE-E0Kv7~=zFEVUzJe0KKW+q?#n$NZ~oPCnjt*fk`n z!F1DNbq8AKHGGU9*QpYoAx`qqQ^#~e>K0}ORXuy>1LkcRK@irW>S;Z4>m+~=1)kPl zDl6DIybkf7BGZMszmZUTnSXa@UvlZ+$DTzWxQJfPavRtx+BGAfCoQ_7$H^di{^xi+ zuiQb3v5IwXZXUclR>2%4GYXl^~ne9wU-<2uLN z7b~XeL`H0P@t=AjPsKb^p<6FL;=8ykYdpkNN87W+<>4pVL}}{5K|OAb0mmsN&+*fA1%f<7iv36dS%+#P8$r{sk{{YT#$lR(fq37bAs@}= z{6L1`JY_dktp1%0H+?Pp2F!Si6dKH=47QIy0!45khzpBF$|@@B;fij7J;F0|TGe-M zf4^`duoT4gcYRYAA`WXEK@)54^)oemDK<)mXSK77e#D6)wE|Jh zwI+)$$QjCG{Y*#+KGhH13C0%d6NnGrp!K>Qm`yzz+ zgI()rI{sFu9-efnRku)}b~kH0)Uuoj+m!Xm8f6={u!f<}fUd;(+Z=VW!jK>_b>Plq zV|UdPp1?ZsvNLIMh>6Ip9oVw}2C`B6hWVkM3a3oA8+*M8dZ#gCVR|G`&Qlu{X?a%g z56`p!U7pN#Tk*P;M!*SZEdH{zb+T}BiKpzT9~Y(^I|myduvK1Z7yMHSwcu_X`&QsP zm@zjh9ke3W1~)zv;uH-^`ZPMVfhgxD>BN_@2ok&HSDM48JYlY7{W4aJ2My3Qh+HVM zEZ0K&X6EhwvsXl2@1|@pNW1WWWm4TyNJ3-7&!q_RHn%YuH>Ox|JF2iw5?Z;BXH|vS zkkIs;CRF56r)|U~5OE-X@WlN%;J?b~{sEQlnx>J6fu$By{2K}ZTZ4RVc=59S0zhU> A5C8xG literal 0 HcmV?d00001 diff --git a/static/icon-512.png b/static/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..7655b70fff0ad241a8412622820ed627d05a1197 GIT binary patch literal 3050 zcmeHJ`&Uy}7T)(J#sq;tSR=?Ac}{}@GC+`MD)>O~QM()s%Ht|iS&D@ST0oRsA4off z*eZyCaIsWorho=n3K0rdS;F9hFl`Ai3nYv}hDIU~1DcRzuFjv(AKRbyS!eBi&ffd` z_T#Hhc5Jb+a<&2ho2{`MKLtRkBMQv-BHuZt5nx%<){Pr>=U$&4`Jvcn0==+n-pc;r z{n+&J4X%}7yOI&@#W)`mt{T70Sl*p{!(P_BZe7yYShn;B@9%GZzcBU7N3)aDZ3_g_ zuj?FM3ZyFvcD?|+eiy$s1%zTfFy76nU#EGXQSV-~5r;g`vV7hAs8M6#K0WXzj~T)% z1=oLSv+aF(5^#O=CKR`JjRD&Oh85cwwyIS%4nlEvw$hDsBcCUp-=^M!rD!4ikSXDe zPLzVmxO?3+qVSA3xcBm8YFJ;d8;dMRJlxP*E_XwQB$F1N zu;ZB7Tp?IU?q5w;E$Um0lAtq;qYo@LZ~jt86kU(L-R^}YXrXV>kZH(kEjvVRCk=f1 zb6lUcBxv1jzFjgw;L?Kzc}dE7+ZVRji57A39kb+^v3$}GJNAJu@j5*bCTH=^!FAFQ zTHUOrs7kR(bAR^vuw%#}`Rs^}+kb}+o^dcgsS#vH(pvo9b4?Z?sLbN1brp0hRon6Z z_kt4yJ_RsqSX)^;#N-UvQz%AXaRX46-wuFyex6XEX!)yvXB zsP+O`)BgD++FK_CPa5rb<<6QR=y30NPfa>lJV8dZ{VkD}8wXhEZP#Nqs!S9jDU~xo z?r`UgIO9NuyRRR1w(|#B(Z%k4w8wM^7fo%;_xaivWcNcf4af!*;$7rVoImjf(sEkY zmD$WbbAt}busN<=&*p%zM6W)A0(UN(Ph-N*gJXl1Km@-l6ko6h;g|X}9|RB3Tmtf= z_W3T1dP`=O_(L#Jw!S?9(s+~b#L2XM`8RJ#fbx{O@J=}Avv(bI= zQ%`t0{J7KwGks+Tb)QdE*vQPsnGo8N^^9SiYATP1Ywe@)VPv}CJ0|Fgvc6#m#YgD4 z?}em{k%3OyI7b z%gb?55=&TcQ98I);nE;T=fGcWc>2A5x%P6|8oD}JppL-Wh)b9?qMBjk&MforhU+7grNR!RpX2%>H4FSBFei;d zO2?h${g8HeDrqvxqW&bjvEwDl<87bMMkJmd5XRYmxX z@Tz}pXS-DUgXG@mwTv8+szmH;vEPZ>n8Gp+Ob#6sw(w$GDMOr*83zgN3+6KabGys`f)YggIWr_FzI(-ZQDi zB<)|)vt&O9!gUpHE$>m1c|_S#{{IFhzpN>!!*qJy3)hKKQQ6-G9md1+USt=K81(rt zj3RQzo=#YgQ~+#bk@JE|gH?B6M0LJ9P>lt?rK{)}sFNZg2N2=Erx*HeZQgv}bV_Gb zPz{utYkSmw_`q9(SfWVtfJ>cgEQ=HM!%H8QpX%jFqHvq`H|ZtyCEWHm?Q?qZ>CERH z`2wR;Yj2rAZ23`ej7!Fh%$7bhWIovWvA^!3-R+kdb4Rsc+sVp}V>vY&JIt@6VV@0N zpI|A@Wwx)_>&VS6&~AeH7tX2M`<_lS$*a@`&>boFMuti?#V2?!5x(G(J``Q#w rDl5-X^h*xl0SNpqepLz-6=pP~Qyx_~*mRlN;bH5h9UE^&V`u*dsTa>; literal 0 HcmV?d00001 diff --git a/static/icon.svg b/static/icon.svg new file mode 100644 index 0000000..c10c806 --- /dev/null +++ b/static/icon.svg @@ -0,0 +1,6 @@ + + + + + diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..5f43064 --- /dev/null +++ b/static/index.html @@ -0,0 +1,90 @@ + + + + + +Card Grader + + + + + + + + + + + + +
+
+ +

Card Grader

+
+
+ + +
+
+ + + +
+ + +
+
+

Grade a card

+ +
+
Front straight-on is the minimum. Adding the back lets it judge + back centering, and it can only tell a print line from a crease — about five PSA + grades apart — if it can check both sides.
+
+ + +
+ + +
+
+ + +
+
+

History

+ +
+
+ + + + + + + + + + + +
CardGradeConfidenceWhen
+
+ +
+ +
+ + + + + + + + + + + + diff --git a/static/manifest.json b/static/manifest.json new file mode 100644 index 0000000..ed155c0 --- /dev/null +++ b/static/manifest.json @@ -0,0 +1,17 @@ +{ + "name": "Card Grader", + "short_name": "Card Grader", + "description": "Estimate a trading card's PSA grade from photos.", + "start_url": "__BASE__/", + "scope": "__BASE__/", + "display": "standalone", + "orientation": "portrait", + "background_color": "#0a0f1c", + "theme_color": "#16224a", + "icons": [ + { "src": "__BASE__/static/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { "src": "__BASE__/static/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, + { "src": "__BASE__/static/icon-512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }, + { "src": "__BASE__/static/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" } + ] +} diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..b9028cb --- /dev/null +++ b/static/style.css @@ -0,0 +1,367 @@ +/* Card Grader — neutral theme, card-type agnostic (Pokemon, sports, TCG). + + Colour discipline: + - Grade severities ride a lightness ladder (none -> minor -> moderate -> + major), so the table still reads as escalating severity in greyscale. + - Red is reserved for "you should distrust this number" (unmeasurable + edges, a low grade) — never decorative. + - Light and dark are separately selected steps, not an inverted flip. */ + +:root { + color-scheme: light; + --surface-1: #ffffff; + --plane: #eef2fa; + --ink: #101828; + --ink-2: #475467; + --muted: #7a8699; + --hairline: #dfe6f2; + --rule: #c3cede; + --accent: #2f6fd0; + --accent-2: #35c6a8; /* mark accent — fills only, never text on light */ + --accent-soft: #dbe8fc; + --good: #10a44a; + --good-text: #077a35; + --warning: #e8912f; + --critical: #d63a3a; + --header-1: #16224a; + --header-2: #24407e; + --header-3: #2f6fd0; + + --ring: rgba(16, 24, 40, .10); + --shadow: 0 1px 2px rgba(16,24,40,.06), 0 8px 24px rgba(16,24,40,.10); + --radius: 13px; +} + +@media (prefers-color-scheme: dark) { + :root:where(:not([data-theme="light"])) { + color-scheme: dark; + --surface-1: #141c2f; + --plane: #0a0f1c; + --ink: #ffffff; + --ink-2: #b6c2d6; + --muted: #8592a8; + --hairline: #253150; + --rule: #35446a; + --accent: #4b8ee8; + --accent-2: #45dcbb; + --accent-soft: #1d3a66; + --good: #22c55e; + --good-text: #34d36a; + --warning: #ffa64d; + --critical: #ef5350; + --header-1: #0d1530; + --header-2: #17285a; + --header-3: #1f4488; + + --ring: rgba(255,255,255,.12); + --shadow: 0 1px 2px rgba(0,0,0,.45), 0 8px 28px rgba(0,0,0,.55); + } +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background-color: var(--plane); + background-image: radial-gradient(circle at 1px 1px, var(--hairline) 1px, transparent 0); + background-size: 22px 22px; + color: var(--ink); + font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; + -webkit-font-smoothing: antialiased; +} + +h1, h2, h3 { margin: 0; font-weight: 600; } +h1 { font-size: 16px; letter-spacing: -.01em; } +h2 { font-size: 15px; } +h3 { font-size: 13px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); font-weight: 600; } + +main { + max-width: 900px; + margin: 0 auto; + padding: 20px 24px 80px; + display: flex; + flex-direction: column; + gap: 20px; +} + +/* ------------------------------------------------------------- topbar */ + +.topbar { + position: sticky; top: 0; z-index: 20; + display: flex; align-items: center; justify-content: space-between; + gap: 16px; flex-wrap: wrap; + padding: 13px 24px; + background: linear-gradient(100deg, var(--header-1), var(--header-2) 55%, var(--header-3)); + color: #fff; + /* Holo-foil edge: the rainbow sweep you get tilting a refractor. Purely + decorative and it encodes nothing, so it's free to be vivid here where + no number lives. */ + border-bottom: 3px solid transparent; + border-image: linear-gradient(90deg, + #ff5f8f, #ffb347, #ffe66d, #35c6a8, #4b8ee8, #a97bff, #ff5f8f) 1; +} +.topbar h1 { color: #fff; letter-spacing: .01em; } + +.topbar .btn { + background: var(--accent-2); color: #06231d; + border-color: transparent; font-weight: 600; +} +.topbar .btn:hover { filter: brightness(1.06); } +.topbar .btn-quiet { + background: rgba(255,255,255,.14); color: #fff; + border-color: rgba(255,255,255,.38); font-weight: 500; +} +.topbar .btn-quiet:hover { background: rgba(255,255,255,.26); filter: none; } + +.brand { display: flex; align-items: center; gap: 11px; } + +/* Brand mark: a graded slab, drawn in plain CSS. A rounded card outline with + a corner clipped and a check — "this one's been looked at" — rather than + any single game's iconography, since this grades anything PSA does. */ +.brand-mark { + position: relative; flex: none; + width: 26px; height: 26px; border-radius: 6px; + background: linear-gradient(160deg, var(--accent-2), var(--accent)); + box-shadow: inset 0 0 0 1.5px rgba(255,255,255,.4), 0 1px 4px rgba(0,0,0,.35); +} +.brand-mark::after { + content: ''; position: absolute; left: 6px; top: 6px; + width: 8px; height: 5px; + border-left: 2px solid #06231d; border-bottom: 2px solid #06231d; + transform: rotate(-45deg); +} + +.topbar-actions { display: flex; gap: 8px; flex-wrap: wrap; } + +/* ------------------------------------------------------------ controls */ + +.btn { + font: inherit; font-weight: 500; font-size: 14px; + padding: 7px 14px; border-radius: 8px; cursor: pointer; + background: var(--accent); color: #fff; + border: 1px solid transparent; + text-decoration: none; display: inline-block; line-height: 1.4; + white-space: nowrap; +} +.btn:hover { filter: brightness(1.07); } +.btn:active { transform: translateY(.5px); } +.btn[disabled] { opacity: .5; cursor: default; filter: none; } + +.btn-quiet { + background: var(--surface-1); color: var(--ink); + border-color: var(--rule); +} +.btn-quiet:hover { background: var(--plane); filter: none; } + +.btn-danger { background: transparent; color: var(--critical); border-color: var(--critical); } +.btn-danger:hover { background: var(--critical); color: #fff; filter: none; } + +.btn-sm { padding: 4px 10px; font-size: 13px; } + +.input, select, textarea { + font: inherit; font-size: 14px; + padding: 7px 10px; + border: 1px solid var(--rule); + border-radius: 8px; + background: var(--surface-1); + color: var(--ink); + width: 100%; +} +.input:focus, select:focus, textarea:focus { + outline: 2px solid var(--accent); outline-offset: -1px; border-color: transparent; +} +textarea { resize: vertical; min-height: 60px; } + +/* --------------------------------------------------------------- tiles */ + +.tile-value { font-size: 25px; font-weight: 700; letter-spacing: -.02em; } +.tile-sub { font-size: 12px; color: var(--ink-2); margin-top: 4px; } +.pos { color: var(--good-text); } +.neg { color: var(--critical); } + +/* -------------------------------------------------------------- panels */ + +.panel { + background: var(--surface-1); + border: 1px solid var(--hairline); + border-radius: var(--radius); + padding: 16px 18px 18px; + box-shadow: 0 1px 2px rgba(16,24,40,.04); +} + +.panel-head { + display: flex; align-items: center; justify-content: space-between; + gap: 12px; flex-wrap: wrap; margin-bottom: 12px; +} +.hint { font-size: 13px; color: var(--muted); } +.search-status { margin-top: 10px; font-size: 13px; color: var(--ink-2); } + +/* --------------------------------------------------------------- table */ + +.table-scroll { overflow-x: auto; } +.grid { width: 100%; border-collapse: collapse; font-size: 14px; } +.grid th { + text-align: left; font-size: 11.5px; font-weight: 600; + text-transform: uppercase; letter-spacing: .05em; color: var(--muted); + padding: 8px 10px; border-bottom: 1px solid var(--rule); white-space: nowrap; +} +.grid td { + padding: 9px 10px; border-bottom: 1px solid var(--hairline); + vertical-align: middle; +} +.grid tbody tr:hover { background: var(--accent-soft); } +.num { text-align: right; font-variant-numeric: tabular-nums; } + +.cardcell { display: flex; align-items: center; gap: 10px; } +.cardcell img, .cardcell .thumb-blank { + width: 40px; height: 56px; object-fit: cover; + border-radius: 4px; background: var(--plane); flex: none; +} +.thumb-blank { + display: inline-block; + border: 1px dashed var(--rule); + background: + repeating-linear-gradient(135deg, transparent 0 5px, var(--hairline) 5px 6px), + var(--plane); +} +.cardcell-name { font-weight: 600; line-height: 1.25; } +.cardcell-meta { font-size: 12px; color: var(--muted); } +.dash { color: var(--muted); } + +/* --------------------------------------------------------------- pills */ + +.pill { + display: inline-flex; align-items: center; gap: 6px; + font-size: 12px; font-weight: 600; + padding: 3.5px 10px; border-radius: 99px; + border: 1px solid var(--rule); color: var(--ink-2); + white-space: nowrap; +} +.pill-grade { border-color: var(--good); color: var(--good-text); + background: color-mix(in srgb, var(--good) 12%, transparent); } +.pill-marginal { border-color: var(--warning); + background: color-mix(in srgb, var(--warning) 14%, transparent); } +.pill-raw { border-color: var(--rule); color: var(--muted); } +.pill-critical { border-color: var(--critical); color: var(--critical); + background: color-mix(in srgb, var(--critical) 12%, transparent); } + +.empty { text-align: center; color: var(--muted); padding: 28px 0 8px; font-size: 14px; } + +/* -------------------------------------------------------------- modal */ + +.scrim { + position: fixed; inset: 0; z-index: 30; + background: rgba(11,11,11,.32); +} +.modal { + position: fixed; z-index: 40; + top: 50%; left: 50%; transform: translate(-50%, -50%); + width: min(560px, calc(100vw - 32px)); + max-height: min(86vh, 900px); overflow-y: auto; + background: var(--surface-1); + border: 1px solid var(--hairline); + border-radius: 14px; + box-shadow: var(--shadow); + padding: 20px 22px; +} +.close { + background: none; border: none; cursor: pointer; font-size: 22px; + color: var(--muted); line-height: 1; padding: 0 4px; +} +.close:hover { color: var(--ink); } + +.section { margin-bottom: 20px; } +.section > h3 { margin-bottom: 10px; } +.fields { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } +.field { display: flex; flex-direction: column; gap: 4px; } +.field.wide { grid-column: 1 / -1; } +.field label { font-size: 12px; color: var(--ink-2); } +.suffix { display: block; font-size: 11.5px; line-height: 1.45; color: var(--muted); } + +.drawer-actions { + display: flex; gap: 8px; flex-wrap: wrap; + padding-top: 14px; border-top: 1px solid var(--hairline); +} +.spacer { flex: 1; } + +.note { + font-size: 12.5px; color: var(--ink-2); + background: var(--plane); border: 1px solid var(--hairline); + border-left: 3px solid var(--warning); + border-radius: 6px; padding: 9px 11px; margin-top: 10px; +} +.note-flag { + border-left-color: var(--critical); + color: var(--critical); + margin-top: 0; margin-bottom: 12px; +} + +/* -------------------------------------------------------------- banner */ + +.banner { + position: sticky; top: 57px; z-index: 15; + padding: 10px 24px; font-size: 13.5px; + background: var(--accent-soft); color: var(--ink); + border-bottom: 1px solid var(--hairline); +} +.banner.err { background: var(--critical); color: #fff; } +.banner ul { margin: 4px 0 0; padding-left: 18px; } + +/* ---------------------------------------------------------- grade slab */ +/* Styled after the label on a graded slab, because that's the thing this + whole app is estimating — it makes the number read as a verdict rather + than as one more statistic on the page. */ + +.slab { + display: flex; align-items: stretch; gap: 0; + border-radius: 10px; overflow: hidden; + border: 1px solid var(--rule); + background: var(--surface-1); + box-shadow: var(--shadow); + max-width: 420px; +} +.slab-main { + flex: 1; padding: 12px 14px; + display: flex; flex-direction: column; justify-content: center; gap: 2px; +} +.slab-title { font-size: 13px; font-weight: 700; line-height: 1.25; } +.slab-sub { font-size: 11.5px; color: var(--muted); } +.slab-grade { + flex: none; width: 104px; + display: flex; flex-direction: column; align-items: center; justify-content: center; + padding: 10px 8px; color: #fff; text-align: center; + background: linear-gradient(150deg, var(--header-2), var(--header-3)); +} +.slab-grade .n { font-size: 34px; font-weight: 800; line-height: 1; letter-spacing: -.02em; } +.slab-grade .word { font-size: 9.5px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; opacity: .85; margin-top: 3px; } + +/* Grade bands. Green for a card worth submitting, amber mid, red low — + the same red reserved elsewhere for "distrust this", used here because a + low grade is genuinely the bad outcome. */ +.slab-10 .slab-grade, .slab-9 .slab-grade { background: linear-gradient(150deg, #0d7a3a, #10a44a); } +.slab-8 .slab-grade, .slab-7 .slab-grade { background: linear-gradient(150deg, #1b6fbf, #2f6fd0); } +.slab-6 .slab-grade, .slab-5 .slab-grade { background: linear-gradient(150deg, #b4700f, #e8912f); } +.slab-low .slab-grade { background: linear-gradient(150deg, #9e2626, #d63a3a); } + +/* A 10 gets the foil treatment. Deliberately reserved for the top grade so + it stays meaningful — every card shimmering would say nothing. */ +.slab-10 .slab-grade { + background: linear-gradient(140deg, #0d7a3a, #35c6a8 40%, #4b8ee8 70%, #a97bff); +} + +.card-type-tag { + display: inline-block; font-size: 10.5px; font-weight: 700; + letter-spacing: .07em; text-transform: uppercase; + padding: 2px 7px; border-radius: 4px; + background: var(--accent-soft); color: var(--accent); + border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent); +} + +/* --------------------------------------------------------------- phone */ + +@media (max-width: 700px) { + main { padding: 14px 12px 60px; } + .topbar { padding: 10px 12px; } + .fields { grid-template-columns: 1fr; } + .grid { font-size: 13.5px; } +} diff --git a/static/sw.js b/static/sw.js new file mode 100644 index 0000000..e958c89 --- /dev/null +++ b/static/sw.js @@ -0,0 +1,62 @@ +/* Service worker — the piece that makes this installable as a real app. + * + * Deliberately minimal, and deliberately network-first for everything. + * Caching the shell aggressively is the usual PWA advice, but here it would + * mean shipping a stale UI against a changed API and calling it offline + * support — this app cannot do anything useful without the network anyway, + * since grading is a live API call. So the cache exists only as a fallback + * for the app shell when the connection drops, and never for /api/. + */ + +// Substituted server-side to the path this app is mounted under (e.g. +// "/cards"), or left empty when served from the domain root — see +// app.py's _static_templated. Every absolute reference below has to go +// through this, since a service worker has no page URL of its own to +// resolve relative paths against. +const BASE = '__BASE__'; + +const CACHE = 'card-grader-v1'; +const SHELL = [ + `${BASE}/`, + `${BASE}/static/app.js`, + `${BASE}/static/style.css`, + `${BASE}/static/icon.svg`, + `${BASE}/static/manifest.json`, +]; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE).then((cache) => cache.addAll(SHELL)).then(() => self.skipWaiting()) + ); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys() + .then((keys) => Promise.all( + keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))) + .then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', (event) => { + const { request } = event; + if (request.method !== 'GET') return; + + const url = new URL(request.url); + // Never cache the API. A stale grade or a stale settings blob would be + // worse than an honest failure. + if (url.pathname.startsWith(`${BASE}/api/`)) return; + + event.respondWith( + fetch(request) + .then((response) => { + if (response && response.ok && url.origin === self.location.origin) { + const copy = response.clone(); + caches.open(CACHE).then((cache) => cache.put(request, copy)); + } + return response; + }) + .catch(() => caches.match(request).then((hit) => hit || caches.match(`${BASE}/`))) + ); +}); diff --git a/store.py b/store.py new file mode 100644 index 0000000..81a7c4c --- /dev/null +++ b/store.py @@ -0,0 +1,202 @@ +"""SQLite: settings and a log of past grade estimates. + +One database, two tables. There is no inventory or pricing here — this app +does exactly one thing (estimate a PSA grade from photos) and remembers what +it told you, so you can look back at a card without re-running the estimate. +""" + +import json +import os +import sqlite3 +import time + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +# Overridable so a container can point this at a mounted volume (e.g. +# /data/grades.db) instead of the app's own directory, which is what makes +# the data survive a container recreate/image update. +DB_PATH = os.environ.get("CARD_GRADER_DB_PATH") or os.path.join(BASE_DIR, "grades.db") + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT +); + +CREATE TABLE IF NOT EXISTS grades ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL, + label TEXT, -- your own name for the card, optional + card_type TEXT, -- pokemon | sports | other_tcg | other + card_note TEXT, -- what the model read off the card + image_count INTEGER, + thumbnail TEXT, -- small JPEG, base64 — first photo only + model TEXT, + estimated_grade INTEGER, + grade_low INTEGER, + grade_high INTEGER, + confidence TEXT, + categories_json TEXT, + edge_measurements_json TEXT, + centering_measurement_json TEXT, + limitations_json TEXT, + note TEXT, + estimated_cost REAL, + usage_json TEXT +); + +CREATE INDEX IF NOT EXISTS idx_grades_created ON grades(created_at DESC); +""" + +DEFAULT_SETTINGS = { + "anthropic_api_key": "", + "vision_model": "claude-sonnet-5", + "vision_effort": "low", +} + + +def connect(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + return conn + + +def init(): + conn = connect() + try: + conn.executescript(SCHEMA) + for key, value in DEFAULT_SETTINGS.items(): + conn.execute( + "INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", + (key, json.dumps(value)), + ) + conn.commit() + finally: + conn.close() + + +def now(): + return time.strftime("%Y-%m-%dT%H:%M:%S") + + +# --------------------------------------------------------------- settings + + +def get_settings(): + conn = connect() + try: + rows = conn.execute("SELECT key, value FROM settings").fetchall() + finally: + conn.close() + out = dict(DEFAULT_SETTINGS) + for row in rows: + try: + out[row["key"]] = json.loads(row["value"]) + except (ValueError, TypeError): + out[row["key"]] = row["value"] + return out + + +def save_settings(updates): + conn = connect() + try: + for key, value in updates.items(): + if key not in DEFAULT_SETTINGS: + continue + conn.execute( + "INSERT INTO settings (key, value) VALUES (?, ?) " + "ON CONFLICT(key) DO UPDATE SET value=excluded.value", + (key, json.dumps(value)), + ) + conn.commit() + finally: + conn.close() + return get_settings() + + +# ------------------------------------------------------------------ grades + + +def save_grade(grade, thumbnail=None, label=None): + """Persist one grading result. Returns the new row's id.""" + conn = connect() + try: + cur = conn.execute( + "INSERT INTO grades " + "(created_at, label, card_type, card_note, image_count, thumbnail, " + " model, estimated_grade, " + " grade_low, grade_high, confidence, categories_json, " + " edge_measurements_json, centering_measurement_json, " + " limitations_json, note, estimated_cost, usage_json) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + now(), label, grade.get("card_type"), grade.get("card_note"), + grade.get("image_count"), thumbnail, + (grade.get("usage") or {}).get("model"), + grade.get("estimated_grade"), grade.get("grade_low"), + grade.get("grade_high"), grade.get("confidence"), + json.dumps(grade.get("categories")), + json.dumps(grade.get("edge_measurements")), + json.dumps(grade.get("centering_measurement")), + json.dumps(grade.get("limitations")), + grade.get("note"), grade.get("estimated_cost"), + json.dumps(grade.get("usage")), + ), + ) + conn.commit() + return cur.lastrowid + finally: + conn.close() + + +def _row_to_grade(row): + d = dict(row) + for key in ("categories_json", "edge_measurements_json", + "centering_measurement_json", "limitations_json", "usage_json"): + out_key = key[:-len("_json")] + raw = d.pop(key, None) + try: + d[out_key] = json.loads(raw) if raw else None + except (ValueError, TypeError): + d[out_key] = None + return d + + +def list_grades(limit=200): + conn = connect() + try: + rows = conn.execute( + "SELECT * FROM grades ORDER BY created_at DESC, id DESC LIMIT ?", + (limit,), + ).fetchall() + finally: + conn.close() + return [_row_to_grade(r) for r in rows] + + +def get_grade(grade_id): + conn = connect() + try: + row = conn.execute("SELECT * FROM grades WHERE id = ?", (grade_id,)).fetchone() + finally: + conn.close() + return _row_to_grade(row) if row else None + + +def update_grade_label(grade_id, label): + conn = connect() + try: + conn.execute("UPDATE grades SET label = ? WHERE id = ?", (label, grade_id)) + conn.commit() + finally: + conn.close() + return get_grade(grade_id) + + +def delete_grade(grade_id): + conn = connect() + try: + conn.execute("DELETE FROM grades WHERE id = ?", (grade_id,)) + conn.commit() + finally: + conn.close() diff --git a/vision.py b/vision.py new file mode 100644 index 0000000..42bd6ef --- /dev/null +++ b/vision.py @@ -0,0 +1,719 @@ +"""Estimate a trading card's PSA grade from photographs, using Claude's vision. + +This is the one part of the app that costs money per use, and the one part +that can be confidently wrong — a model eyeballing a photo can miss real wear +or invent damage that isn't there. The contract here is deliberately narrow: + + * it returns an *estimate*, with a range and a confidence, never a verdict + * every category can honestly say "cannot_assess" rather than guess + * two of PSA's four categories (centering, edge whitening) are measured + directly from the pixels in cardimage.py and handed to the model as + numbers, rather than asked for by eye — see that module for why + +Works on any trading card: Pokemon, sports, Magic, whatever PSA grades. +""" + +import base64 +import json +import os + +import cardimage + +try: + import anthropic +except ImportError: # keeps the rest of the app importable without the SDK + anthropic = None + +# Per-model capabilities. These differ in ways that are 400 errors, not +# preferences, so the request is built from this table rather than assuming a +# single shape: +# * `effort` is rejected outright by Haiku 4.5 — on that model "cheapest" +# means omitting `thinking`, which turns thinking off entirely. +# * `fallbacks` (server-side refusal recovery) only applies to the Opus/Fable +# tier; sending it elsewhere isn't supported. +# * image token cost differs because Haiku caps images at 1568px on the long +# edge while Sonnet 5 and Opus 5 accept 2576px. Oversized images are scaled +# down server-side, so there's nothing to do client-side either way. +MODELS = { + "claude-haiku-4-5": { + "label": "Haiku 4.5 — cheapest", + "effort": False, # sending output_config.effort is a 400 + "adaptive_thinking": False, + "fallbacks": False, + "in_per_mtok": 1.00, "out_per_mtok": 5.00, + "approx_image_tokens": 1600, + }, + "claude-sonnet-5": { + "label": "Sonnet 5 — balanced", + "effort": True, + "adaptive_thinking": True, + "fallbacks": False, + # Introductory pricing runs through 2026-08-31, then 3.00 / 15.00. + "in_per_mtok": 2.00, "out_per_mtok": 10.00, + "approx_image_tokens": 4800, + }, + "claude-opus-5": { + "label": "Opus 5 — most accurate", + "effort": True, + "adaptive_thinking": True, + "fallbacks": True, + "in_per_mtok": 5.00, "out_per_mtok": 25.00, + "approx_image_tokens": 4800, + }, +} + +# Grading rewards the extra reasoning a thinking-capable model does — telling +# a print line from a crease, a reflection from real whitening — so this +# defaults to Sonnet regardless of what a cost-conscious default might +# otherwise pick. Tested directly against Haiku on the same cards: Haiku +# inverted a PSA centering-tolerance comparison and missed a grade by three +# levels on a card Sonnet read correctly. The per-grade cost difference is a +# few cents; a wrong grade estimate costs more than that. +DEFAULT_MODEL = "claude-sonnet-5" +DEFAULT_EFFORT = "low" + +# On thinking-capable models this budget covers thinking *and* the JSON, since +# max_tokens caps their sum. On Haiku there's no thinking, so it's just the JSON. +MAX_TOKENS = 8000 + +SUPPORTED_MEDIA = { + "png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", + "gif": "image/gif", "webp": "image/webp", +} + + +class VisionError(Exception): + pass + + +def available(): + """Is the feature usable right now?""" + return anthropic is not None and bool(_api_key()) + + +def _api_key(): + return os.environ.get("ANTHROPIC_API_KEY", "").strip() + + +def media_type(filename, image_bytes=None): + """The MIME type to declare for an image. + + Prefers what the bytes actually are over what the filename claims — + filenames arriving from a phone are frequently useless (no extension, a + content:// URI, a .HEIC that is really being converted upstream), and + refusing a readable image because of its name is the wrong failure. + """ + if image_bytes: + sniffed = cardimage.sniff_format(image_bytes) + if sniffed in cardimage.DIRECTLY_SUPPORTED: + return "image/{}".format(sniffed) + ext = (filename or "").rsplit(".", 1)[-1].lower() + return SUPPORTED_MEDIA.get(ext) + + +def _call_vision(images, system, schema, prompt, api_key=None, model=None, + effort=None, max_tokens=None, labels=None): + """Shared plumbing for every vision call: auth, request shape per model + capability, and error/refusal handling. Returns (parsed_json, usage_dict). + + Raises VisionError with a human-readable message on any failure — callers + surface it in the UI rather than half-committing anything. + """ + if anthropic is None: + raise VisionError( + "The anthropic package isn't installed. Run: " + "python3 -m pip install --user anthropic" + ) + if not images: + raise VisionError("No images to analyze.") + + key = (api_key or _api_key()) or None + if not key: + raise VisionError( + "No Anthropic API key set. Add one in Settings, or export " + "ANTHROPIC_API_KEY before starting the app." + ) + + model = model if model in MODELS else DEFAULT_MODEL + caps = MODELS[model] + effort = effort or DEFAULT_EFFORT + + client = anthropic.Anthropic(api_key=key) + + content = [] + for index, (image_bytes, filename) in enumerate(images): + mime = media_type(filename, image_bytes) + if not mime: + raise VisionError( + "Unsupported image type '{}'. Use PNG, JPEG, GIF or WebP.".format(filename) + ) + # A caption immediately before its image is far more reliable than + # describing the running order once up front — with nine images in a + # grading request, positional bookkeeping is exactly what a model + # loses track of, and mislabelling which edge is worn is worse than + # not reporting it. + if labels and index < len(labels) and labels[index]: + content.append({"type": "text", "text": labels[index]}) + encoded = base64.standard_b64encode(image_bytes).decode("utf-8") + content.append({"type": "image", + "source": {"type": "base64", "media_type": mime, "data": encoded}}) + content.append({"type": "text", "text": prompt}) + + params = { + "model": model, + "max_tokens": max_tokens or MAX_TOKENS, + "system": system, + "output_config": {"format": {"type": "json_schema", "schema": schema}}, + "messages": [{"role": "user", "content": content}], + } + + if caps["effort"]: + # Effort is the right lever for these models. Deliberately not + # disabling thinking here: the cheap path is Haiku (which has no + # thinking at all), and a thinking-disabled Opus gives up the exact + # capability you'd be paying for. + params["output_config"]["effort"] = effort + + try: + if caps["fallbacks"]: + # Safety classifiers can decline a request outright; a fallback + # re-runs it on another model server-side instead of failing. + response = client.beta.messages.create( + betas=["server-side-fallback-2026-07-01"], + fallbacks="default", + **params, + ) + else: + response = client.messages.create(**params) + except anthropic.AuthenticationError: + raise VisionError("Anthropic rejected the API key. Check it in Settings.") + except anthropic.PermissionDeniedError: + raise VisionError("That API key doesn't have access to {}.".format(model)) + except anthropic.RateLimitError: + raise VisionError("Anthropic is rate-limiting you. Wait a moment and retry.") + except anthropic.BadRequestError as exc: + raise VisionError("Anthropic rejected the request: {}".format(exc)) + except anthropic.APIConnectionError: + raise VisionError("Couldn't reach Anthropic. Check your connection.") + except anthropic.APIStatusError as exc: + raise VisionError("Anthropic error {}: {}".format(exc.status_code, exc)) + + # A refusal returns HTTP 200 with empty/partial content — check before reading. + if response.stop_reason == "refusal": + raise VisionError( + "Claude declined to analyze this image. Try a different photo." + ) + if response.stop_reason == "max_tokens": + raise VisionError( + "The response was cut off. Try fewer/simpler images at a time." + ) + + text = next((b.text for b in response.content if b.type == "text"), None) + if not text: + raise VisionError("Claude returned no readable result for this image.") + + try: + parsed = json.loads(text) + except ValueError: + raise VisionError("Claude's response wasn't valid JSON.") + + usage = response.usage + return parsed, { + "input_tokens": getattr(usage, "input_tokens", None), + "output_tokens": getattr(usage, "output_tokens", None), + "model": response.model, + } + + +GRADING_SYSTEM = """You estimate what PSA grade a trading card would likely \ +receive, from photograph(s) of it. The card may be Pokemon, another trading \ +card game, or a sports card — PSA grades all of them on the same four \ +criteria. + +Be honest about the ceiling on this. Graders work with the physical card under \ +raking light and magnification; you have a photo. Surface scratches, print \ +lines, dimples, and light edge whitening are frequently invisible in a normal \ +photo — especially a seller's listing photo, which is often deliberately lit \ +to hide them. Centering is the one thing a straight-on photo shows reliably. \ +So: assess what you can actually see, say plainly what you cannot, and let \ +the estimate range reflect that uncertainty rather than projecting false \ +precision. + +PSA weighs four things. Assess each one separately: + +- CENTERING: the ratio of the border widths on opposing sides. PSA publishes \ +hard tolerances for this, applied to the FRONT (the back is judged far more \ +leniently, 75/25 for a 10 and 90/10 from 9 downwards): + 55/45 to 60/40 .... allows a 10 + 60/40 ............. allows a 9 + 65/35 ............. allows an 8 + 70/30 ............. allows a 7 + 80/20 ............. allows a 6 + 85/15 ............. allows a 5 or 4 + 90/10 ............. allows a 3 or 2 + Both axes are judged and the WORSE one governs, so a card at 52/48 \ + left-to-right but 70/30 top-to-bottom is a 70/30 card and caps at 7. + When a MEASURED CENTERING block is supplied, use those figures — they are \ + computed from the pixels and are accurate to within a couple of percentage \ + points, which is finer than this can be eyeballed. Treat a measured ratio \ + as authoritative over your visual impression unless the photo is clearly \ + taken at an angle, which stretches one border and invalidates the geometry. + Where no measurement is given, judge it from a straight-on shot only. An \ + angled photo distorts borders in exactly the way that mimics or hides a \ + centering problem, so say you cannot assess it rather than guessing. + Note that centering is a manufacturing trait, not damage: a badly centred \ + card can still be pristine, and PSA may grade it strongly with an OC \ + (off-centre) qualifier rather than a low number. +- CORNERS: look for whitening, fraying, softness, or blunting at each of the \ +four corners. Sharp corners on all four is 9-10 territory; slight whitening \ +visible under magnification but not to the eye is 8-9; obvious whitening or \ +rounding drops it further. Assess all four separately and let the worst one \ +drive the category — graders do not average corners. + When magnified corner close-ups are provided, judge this category from \ + them rather than from the full-card photo. They are digitally cropped and \ + UPSCALED from that same photo, which means they add no information the \ + original didn't contain: they only make existing detail easier to see. So \ + treat softness, blur, or smeared edges that look like resampling artefacts \ + as artefacts, NOT as card damage. Real corner wear looks like fibrous \ + white paper showing through a coloured border, or a visibly blunted or \ + bent tip — not a uniformly soft edge. If a close-up is simply too blurry \ + to tell the difference, say cannot_assess. +- EDGES: look for whitening, nicks, chipping, or roughness along the four \ +edges. Assess top, right, bottom and left separately and let the worst one \ +drive the category — graders do not average edges. + Report whitening you can actually see. A visible white or light band along \ + a cut edge is real wear and belongs in this category even if it is thin, \ + even if it runs along only part of one side, and even if the rest of the \ + card looks clean — a single whitened edge is routinely the difference \ + between a 9 and a 7. Do not talk yourself out of something visible on the \ + grounds that it "might be lighting": if a light band follows the cut line \ + consistently, call it. Say cannot_assess only when you genuinely cannot \ + see the edge, not when you can see it and are unsure how bad it is. + Context that changes what counts as normal, not whether to report it: \ + dark-bordered cards (1971 Topps, many modern chrome/prizm parallels) show \ + the same amount of wear far more obviously than light-bordered ones, and \ + white-bordered cards can hide it almost entirely — so on a white border, \ + look for a change in texture or a frayed cut line rather than a colour \ + change. + When magnified edge strips are provided, judge this category from them. +- SURFACE: look for scratches, print lines, indentations, creases, staining, \ +loss of gloss, or foil/holo scratching. + When SURFACE INSPECTION images are provided, judge this category from them, \ + and do not answer cannot_assess without saying which specific thing you \ + could not check. Each of those images is one region of the card shown \ + TWICE: the untouched crop on the left, and on the right a processed version \ + that cancels the artwork and leaves only fine surface texture. A scratch \ + that is invisible on the left is often obvious on the right, which is the \ + whole point of showing both. + Read them together. The right panel tells you WHERE something is; the left \ + panel tells you WHAT it is. Only call something surface damage when it makes \ + sense in both: a real scratch is a thin line that runs across artwork and \ + text alike, ignoring the picture's own content, and it stays in the same \ + place in both panels. + Things that light up in the right panel and are NOT damage — do not report \ + these: the outline of every letter and number (text edges always glow); the \ + border between two areas of artwork; the regular dot or rosette pattern of \ + the printing itself; the deliberate texture on holo, foil, etched and \ + reverse-holo cards; and blocky square patterns from image compression. If \ + the right panel is uniformly busy rather than showing distinct lines, that \ + is print texture, not damage. + PRINT LINES are the important exception to that, and they are easy to \ + dismiss as holo texture when they are not. A print line is a STRAIGHT band \ + running parallel to one edge, usually spanning most or all of the card's \ + width or height, of even thickness along its whole length, and it cuts \ + straight across artwork, text and background alike without regard for any \ + of them. Holo and refractor patterns radiate, swirl, or scatter; a print \ + line does not — it is mechanical, made by a roller, and looks it. On a \ + foil, refractor or chrome card it often shows as a band where the shimmer \ + is interrupted or duller than the rest. Look specifically for one across \ + each third of the card, name where it runs, and report it — PSA treats it \ + as a print defect (the PD qualifier) and a pronounced one caps the grade \ + regardless of how clean everything else is. + Genuinely unassessable cases still exist — heavy glare hiding a whole \ + region, a photo out of focus, or a holo pattern so strong it would mask a \ + scratch. Say so specifically when that happens. But a normal, in-focus \ + photo with these inspection images is enough to reach a real answer, so \ + reaching for cannot_assess by default is not the honest choice here — it is \ + just the uninformative one. + On chrome and refractor stock, expect fine scratching; it is the norm rather \ + than the exception, and its absence is what is notable. + +HOW PSA COMBINES THE FOUR + +The grade is capped by the WORST attribute, not averaged across them. Three \ +pristine categories and one clear problem is a card graded on the problem. \ +Work out the ceiling each category allows and take the lowest. + +What each grade tolerates, in practice: + 10 GEM-MT four sharp corners, full original gloss, no staining, sharp \ + focus. One slight print imperfection is allowed. + 9 MINT essentially a 10 with exactly ONE minor flaw — a slight wax \ + stain on the back, a minor print imperfection, or slightly \ + off-white borders. + 8 NM-MT looks 9 at a glance; on close inspection the slightest fraying \ + at one or two corners, a minor print imperfection. + 7 NM slight surface wear visible on close inspection, slight corner \ + fraying, a minor print blemish. + 6 EX-MT visible surface wear or a print defect. A very light scratch \ + found only on close inspection. Graduated corner fraying. Minor \ + edge chipping. + 5 EX minor corner rounding becoming evident, more visible surface \ + wear, minor chipping at the edges. + 4 VG-EX slightly rounded corners with moderate fraying, light scuffing \ + or scratching. + 3 VG rounded corners, obvious surface wear and scratching. + 2 GOOD badly frayed or rounded corners, advanced wear, creasing. + 1 PR-FR heavy wear, major creasing, possible writing or tape. + +Rules that override the category-by-category read: +- A CREASE is not ordinary surface wear. Any clear crease or fold caps a card \ + in the low single digits (roughly 3 or below, 2 if pronounced) no matter how \ + clean everything else looks. Because that verdict is so severe, do not reach \ + it by elimination — separate a crease from a PRINT LINE deliberately, since \ + the two look alike in a photo and are about five grades apart: + A PRINT LINE is perfectly straight, of even thickness, runs parallel to an \ + edge, and appears only on the printed side. The card is not deformed; the \ + ink simply differs along that band. This is a print defect (PD), and on \ + its own it does not stop a card grading in the 6-8 range. + A CREASE breaks the card itself. It usually shows a paired light-and-dark \ + line where the surface bends and catches light differently, tends to \ + wander rather than run perfectly straight, often runs at an angle or fades \ + out mid-card, and shows on BOTH sides — so if a back photo is supplied and \ + the mark is absent there, it is almost certainly not a crease. + When the evidence genuinely does not separate the two, say so and give the \ + benefit of the doubt to the print line, noting that a back photo would \ + settle it. Do not cap a card at 3 on a maybe. +- A PRINT DEFECT is not handling damage. Print lines, dots, roller marks and \ + slight colour registration errors happen at the factory, and PSA tolerates a \ + minor one even at 10. Do not grade these like scratches and wear; note them \ + separately. Severe ones do drag the grade and may earn a PD qualifier. +- The BACK is graded too, and you usually cannot see it. When only a front \ + photo is given, say so in limitations: a back-only flaw such as a wax stain \ + or poor back centering is invisible to you and can pull the real grade below \ + your estimate. This is a reason to keep the range open at the bottom. +- Cards strong everywhere except one attribute may receive a QUALIFIER instead \ + of a low grade — OC (off-centre), PD (print defect), ST (stain), MK (marks), \ + MC (miscut). Worth mentioning when the pattern fits, since a "PSA 8 OC" is a \ + different market proposition from a plain PSA 5. + +Vintage cards (roughly pre-1980) are graded on the same scale but almost never \ +come back 9-10 — original cutting and centering were far less consistent, so \ +temper the estimate accordingly rather than assuming a clean-looking vintage \ +card is a high grade. Conversely, do not penalise a vintage card twice for the \ +era-typical soft cut that its grade already accounts for. + +WHAT IS NORMAL FOR THIS KIND OF CARD + +PSA applies the SAME four criteria and the same centering tolerances to every \ +card, so there is no separate rubric to switch to. What changes between card \ +types is the base rate — how common a given flaw is, and therefore how much \ +seeing it (or not seeing it) should move your estimate. Set card_type to what \ +you actually see, then calibrate with the notes below. Do not report a flaw \ +you cannot see just because it is common; this is about how to weigh what you \ +DO see. + +If card_type is "sports": +- Wax stains on the back exist only on wax-pack-era cards (roughly 1950s-80s). \ + PSA explicitly tolerates a slight one even at 9. Never invent one; if a back \ + photo shows a translucent greasy patch, that's what it is. +- Print dots, snow and light print speckling are endemic to late-80s/early-90s \ + mass-produced sets. A minor one is a print defect, not handling damage. +- Centering on 1960s-70s Topps is notoriously poor — 70/30 or worse is typical \ + rather than exceptional, and the measured ratio should drive the grade \ + without extra editorialising about it. +- Rough or "diamond" cuts are factory-normal on O-Pee-Chee and some older \ + Topps. That's a cut characteristic, not edge wear. +- 1971 Topps and other black-bordered sets show every speck of corner and edge \ + wear. Judge the actual amount visible, not the visual impression the border \ + creates. +- Modern chrome stock (Prizm, Optic, Select, Topps Chrome) scratches readily; \ + fine surface scratching is the norm and its absence is what's notable. +- Tobacco-era cards (T206 and similar, pre-1920) were hand-cut and are \ + essentially never well-centred or sharp-cornered. A 5 is a strong grade there. + +If card_type is "pokemon" or "other_tcg": +- WOTC-era Pokemon holos (1999-2003, Base through Skyrim) have a holo layer \ + that scratches extremely easily. Fine scratching across the holo window is \ + close to universal; a genuinely clean one is unusual and worth saying so. +- Dark and black-bordered sets (Team Rocket, Neo Destiny, older Magic) show \ + edge whitening dramatically. Again, weigh the amount actually visible. +- Modern Pokemon ultra-rares (VMAX, ex, full art, Trainer Gallery) have \ + DELIBERATE textured or etched surfaces. That texture is manufacturing, not \ + damage, and must never be reported as scratching or roughness. +- Factory print lines are common on modern holo sheets — treat as a print \ + defect (PD), not handling wear. See the crease-vs-print-line rule above. +- Yu-Gi-Oh 1st Edition ultra/secret rares frequently bow or warp slightly from \ + the foil layer. That is a manufacturing trait, NOT a crease or bend, and \ + should not collapse the grade the way a real crease would. +- Japanese Pokemon cards are generally better centred and better cut than \ + their English counterparts, so a poorly centred Japanese card is a more \ + meaningful finding than the same ratio on an English one. + +For each category give a severity: "none" (no issues visible), "minor", \ +"moderate", "major", or "cannot_assess" when the photo genuinely doesn't \ +support a judgement. Use "cannot_assess" freely — it is far more useful than \ +a confident guess, and a "none" that really meant "I couldn't see any because \ +the photo is too small" is actively misleading. + +Then give: +- estimated_grade: your single best estimate, a whole number 1-10. +- grade_low and grade_high: the realistic range this card could come back in, \ +given what you could and couldn't assess. If you couldn't assess surface or \ +centering, that range should be genuinely wide (e.g. 6-9), not cosmetic. +- confidence: "high" only for a sharp, straight-on, high-resolution photo \ +where you could assess all four categories; "medium" when one or two \ +categories are unassessable; "low" when the photo mainly supports identifying \ +the card rather than grading it. +- limitations: list each specific thing the photo prevented you from checking \ +("back not shown, so back centering and back corners are unknown", "resolution \ +too low to see print lines or light surface scratches"). + +Never describe this as what the card *will* grade. It is an estimate of what \ +it might grade, from a photo.""" + +GRADE_CATEGORY_SCHEMA = { + "type": "object", + "properties": { + "severity": { + "type": "string", + "enum": ["none", "minor", "moderate", "major", "cannot_assess"], + }, + "observation": { + "type": "string", + "description": "What you specifically saw (or why you couldn't assess it).", + }, + }, + "required": ["severity", "observation"], + "additionalProperties": False, +} + +GRADING_SCHEMA = { + "type": "object", + "properties": { + "card_type": { + "type": "string", + "enum": ["pokemon", "sports", "other_tcg", "other"], + "description": "Drives which base-rate notes apply; the rubric itself is identical.", + }, + "card_note": { + "type": "string", + "description": "One line naming the card if legible (player/name, set, year) — for the history list.", + }, + "estimated_grade": { + "type": ["integer", "null"], + "description": "Best single estimate, whole number 1-10, or null if ungradeable from these photos.", + }, + "grade_low": {"type": ["integer", "null"]}, + "grade_high": {"type": ["integer", "null"]}, + "confidence": {"type": "string", "enum": ["high", "medium", "low"]}, + "centering": GRADE_CATEGORY_SCHEMA, + "corners": GRADE_CATEGORY_SCHEMA, + "edges": GRADE_CATEGORY_SCHEMA, + "surface": GRADE_CATEGORY_SCHEMA, + "limitations": { + "type": "array", "items": {"type": "string"}, + "description": "Specific things these photos prevented you from checking.", + }, + "note": {"type": "string", "description": "Short overall summary."}, + }, + "required": ["card_type", "card_note", "estimated_grade", "grade_low", + "grade_high", "confidence", "centering", "corners", "edges", + "surface", "limitations", "note"], + "additionalProperties": False, +} + + +def _clean_grade(value): + if not isinstance(value, (int, float)) or isinstance(value, bool): + return None + return max(1, min(10, int(value))) + + +def _clean_category(raw): + raw = raw if isinstance(raw, dict) else {} + severity = raw.get("severity") + if severity not in ("none", "minor", "moderate", "major", "cannot_assess"): + severity = "cannot_assess" + return {"severity": severity, "observation": raw.get("observation") or ""} + + +def grade_card(images, api_key=None, model=None, effort=None, zoom_details=True): + """Estimate the PSA grade a card would likely receive, from photo(s). + + `images` is a list of (image_bytes, filename) pairs. More angles help a + lot here — front, back, and corner close-ups each unlock a category the + others can't show. Returns the estimate, a range, per-category findings, + and what the photos couldn't support judging. + + When `zoom_details` is on and Pillow is installed, magnified crops of the + first photo's four corners and four edge strips are generated and sent + alongside it. A corner or an edge band is a tiny part of a full-card + frame, so after the model's own downscaling there is often too little + left to judge — which is why those categories came back "cannot_assess" + or missed visible whitening. Cropping first preserves that detail. + """ + supplied = len(images) + crops = [] + if zoom_details and images and cardimage.available(): + crops = cardimage.detail_crops(images[0][0], images[0][1]) + + labels = [] + for i in range(supplied): + labels.append("FULL CARD photo{}:".format( + "" if supplied == 1 else " {} of {}".format(i + 1, supplied))) + for _, name in crops: + stem = name.rsplit(".", 1)[0] + if "-surface-" in stem: + quadrant = stem.split("-surface-", 1)[1].upper() + labels.append( + "SURFACE INSPECTION of the {} QUADRANT. Left half: the crop as " + "photographed. Right half: the same crop with the artwork " + "cancelled out so only fine surface texture remains. Use the " + "pair together to judge surface in this quadrant.".format(quadrant)) + continue + region = "-".join(stem.rsplit("-", 2)[-2:]) + if region.endswith("-edge"): + side = region[:-len("-edge")].upper() + labels.append( + "MAGNIFIED STRIP along the {} EDGE of the card. Real whitening " + "is an UNEVEN pale band of varying width along the cut. The " + "thin uniform line at the very boundary is the cut itself, " + "which every card has — do not report that as whitening. " + "Judge the {} edge from this image.".format(side, side.lower())) + else: + labels.append( + "MAGNIFIED CLOSE-UP of the {} CORNER.".format(region.upper())) + + can_measure = zoom_details and images and cardimage.available() + measured = cardimage.edge_wear_profile(images[0][0]) if can_measure else None + centering = cardimage.centering_profile(images[0][0]) if can_measure else None + + prompt_parts = [] + if centering: + prompt_parts.append( + "MEASURED CENTERING — computed from the pixels by finding the " + "border on each side of the card:\n" + " left/right: {} (borders {}px and {}px)\n" + " top/bottom: {} (borders {}px and {}px)\n" + " worst axis: {:.0f}/{:.0f}\n" + "These are accurate to within about two percentage points, so use " + "them directly against the PSA centering tolerances rather than " + "estimating by eye. Disregard them only if the card is clearly " + "photographed at an angle, since that distorts the border widths " + "geometrically — say so if you think that is the case.".format( + centering["horizontal_label"], + centering["widths_px"]["left"], centering["widths_px"]["right"], + centering["vertical_label"], + centering["widths_px"]["top"], centering["widths_px"]["bottom"], + centering["worst"], 100 - centering["worst"])) + if measured and not measured.get("reliable"): + prompt_parts.append( + "EDGE WHITENING COULD NOT BE MEASURED on this card, because {}. " + "You are getting no numbers for it, so judge the edges from the " + "strip images alone — and judge them CONSERVATIVELY. On a border " + "like this a pale edge is usually the border itself or a " + "reflection rather than wear, so call whitening only where an " + "uneven band clearly differs from the rest of that same edge. When " + "unsure prefer cannot_assess over assuming wear: wrongly calling " + "whitening costs the card a grade it should have kept.".format( + measured.get("reason", "of its finish"))) + elif measured: + rows = [] + for side in ("top", "right", "bottom", "left"): + data = (measured.get("edges") or {}).get(side) + if data: + rows.append(" {} edge: {:.1f}% of its length".format( + side, data["percent"])) + if rows: + prompt_parts.append( + "MEASURED EDGE WHITENING — computed directly from the pixels, " + "not estimated by eye:\n" + "\n".join(rows) + "\n" + "Each figure is the share of that edge whose outermost border " + "is both lighter and less saturated than the SAME border a few " + "pixels further in, which is the signature of paper core " + "showing through. Because each column is compared against " + "itself, it is unaffected by the border's colour, by the " + "overall exposure, or by the cut line that every card has — " + "the three things that make whitening so easy to misjudge by " + "eye.\n" + "Read them comparatively: an edge far above the others on the " + "same card is the real finding. Roughly, under 15% is a clean " + "edge; 15-30% is ambiguous and is as often a lighting " + "highlight, a drop shadow, or a printed bevel along that side " + "as it is wear, so do NOT call it wear unless you can also see " + "an uneven pale band in that edge's strip image; 30-60% is " + "clear whitening; above 60% is heavy. A card whose worst edge " + "is under 15% has clean edges — say so plainly rather than " + "hunting for something to report.\n" + "Let these numbers lead the edges category, and use the edge " + "strip images to describe what the wear looks like and to " + "catch what the measurement does not look for at all, such as " + "a nick, a chip, or a crushed edge.") + if crops: + prompt_parts.append( + "The close-ups above are upscaled crops of the full card photo, each " + "captioned with the exact region it came from — trust those captions " + "when you say which corner or edge a problem is on. They add no " + "information the full photo lacked, only easier viewing, so do not " + "read resampling softness as card wear.") + prompt_parts.append("Estimate the PSA grade this trading card would likely receive.") + + parsed, usage = _call_vision( + list(images) + crops, GRADING_SYSTEM, GRADING_SCHEMA, " ".join(prompt_parts), + api_key, model, effort, max_tokens=MAX_TOKENS, labels=labels, + ) + + low = _clean_grade(parsed.get("grade_low")) + high = _clean_grade(parsed.get("grade_high")) + if low is not None and high is not None and low > high: + low, high = high, low + + card_type = parsed.get("card_type") + if card_type not in ("pokemon", "sports", "other_tcg", "other"): + card_type = "other" + + return { + "closeups": len(crops), + "card_type": card_type, + "card_note": (parsed.get("card_note") or "").strip(), + "edge_measurements": measured, + "centering_measurement": centering, + "estimated_grade": _clean_grade(parsed.get("estimated_grade")), + "grade_low": low, + "grade_high": high, + "confidence": parsed.get("confidence") or "low", + "categories": { + "centering": _clean_category(parsed.get("centering")), + "corners": _clean_category(parsed.get("corners")), + "edges": _clean_category(parsed.get("edges")), + "surface": _clean_category(parsed.get("surface")), + }, + "limitations": [l for l in (parsed.get("limitations") or []) if l], + "note": parsed.get("note") or "", + "usage": usage, + } + + +def estimate_cost(usage): + """Actual USD cost of one grading call, from the reported token counts.""" + if not usage: + return None + caps = MODELS.get(usage.get("model") or "", MODELS[DEFAULT_MODEL]) + return ((usage.get("input_tokens") or 0) * caps["in_per_mtok"] / 1_000_000 + + (usage.get("output_tokens") or 0) * caps["out_per_mtok"] / 1_000_000) + + +def price_guide(): + """Per-model cost of a typical grading call (1 photo + 12 close-ups).""" + guide = {} + for model_id, caps in MODELS.items(): + # 1 supplied photo + ~12 generated close-ups, JSON verdict out. + est_in = caps["approx_image_tokens"] * 5 + 600 + est_out = 700 + guide[model_id] = { + "label": caps["label"], + "supports_effort": caps["effort"], + "per_grade": round(est_in * caps["in_per_mtok"] / 1_000_000 + + est_out * caps["out_per_mtok"] / 1_000_000, 4), + } + return guide