Initial commit: Card Grader deployed to hippofam.com/cards
PWA card-grading app, deployed behind Nginx Proxy Manager on Unraid with basic auth. Includes CARD_GRADER_BASE_PATH support for running under a sub-path, and Docker/compose config for the Unraid deployment.
This commit is contained in:
commit
c7bd71a3e1
19 changed files with 3618 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
grades.db
|
||||||
|
data/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
16
Dockerfile
Normal file
16
Dockerfile
Normal file
|
|
@ -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"]
|
||||||
289
README.md
Normal file
289
README.md
Normal file
|
|
@ -0,0 +1,289 @@
|
||||||
|
# Card Grader
|
||||||
|
|
||||||
|
A local web app that estimates a trading card's PSA grade from photographs.
|
||||||
|
Works on any card PSA grades — Pokemon, sports, Magic, whatever — and keeps
|
||||||
|
a local history of everything you've checked.
|
||||||
|
|
||||||
|
This is the single-purpose extraction of the grading feature out of a larger
|
||||||
|
Pokemon card flipping tracker. No inventory, no price lookups, no catalog —
|
||||||
|
just "how would this card likely grade," and a log of what you've graded.
|
||||||
|
|
||||||
|
## Why this is more than "ask Claude to look at a photo"
|
||||||
|
|
||||||
|
Two of PSA's four grading categories are *measured* directly from the pixels
|
||||||
|
in `cardimage.py`, rather than asked for by eye:
|
||||||
|
|
||||||
|
- **Edge whitening.** The outermost sliver of each edge is compared,
|
||||||
|
column by column, against the card's own unworn border. The comparison is
|
||||||
|
local (outer band vs. the band just inside it), which is what tells real
|
||||||
|
wear apart from glare or a glossy sleeve reflection — both raise the whole
|
||||||
|
region evenly and produce no local step, while paper showing through at a
|
||||||
|
worn cut does. On a white, silver, foil or refractor border — where the
|
||||||
|
measurement's core assumption breaks down — it refuses to report a number
|
||||||
|
at all rather than guess, because a wrong accusation of wear is worse than
|
||||||
|
no answer.
|
||||||
|
- **Centering.** The border width on each of the four sides is found and
|
||||||
|
turned into left/right and top/bottom ratios, accurate to within about two
|
||||||
|
percentage points. PSA publishes hard centering tolerances (55/45 for a 10,
|
||||||
|
60/40 for a 9, 65/35 for an 8...), so this is the one category that gets
|
||||||
|
checked against the actual published standard instead of estimated.
|
||||||
|
|
||||||
|
On top of that, a full-card photo is cut into twelve close-ups before
|
||||||
|
grading: the four corners and four edge strips magnified (a corner is a tiny
|
||||||
|
fraction of a full-card frame — by the time a vision model finishes scaling
|
||||||
|
the whole photo down, there's often nothing left to judge it from), plus four
|
||||||
|
surface quadrants each paired with a processed copy that cancels the artwork
|
||||||
|
so scratches and print lines survive contrast against holo texture and
|
||||||
|
halftone dots.
|
||||||
|
|
||||||
|
All of this was arrived at by testing, not by guessing — corner/edge/surface
|
||||||
|
handling was tuned against synthetic cards with known, deliberately-planted
|
||||||
|
defects (and known-clean controls) until false positives on clean cards
|
||||||
|
dropped to zero while genuine wear still detected. Read the comments in
|
||||||
|
`cardimage.py` and `vision.py` if you want the specifics; several approaches
|
||||||
|
that sounded reasonable (a saturation-based edge map, testing each edge only
|
||||||
|
against itself) measurably backfired and were reverted, with the reasoning
|
||||||
|
left in place so they don't get re-tried.
|
||||||
|
|
||||||
|
**The honest limit, stated plainly:** a photo cannot show everything a
|
||||||
|
grader's raking light and magnification can. Surface scratches, light edge
|
||||||
|
wear, and the entire back of the card are frequently invisible in a normal
|
||||||
|
photo — especially a seller's listing photo, which is often lit specifically
|
||||||
|
to hide them. The model is instructed to say `cannot_assess` rather than
|
||||||
|
guess, and to widen the estimated range when it genuinely can't tell. This is
|
||||||
|
a rough screen to help you decide whether a card is worth sending in, not a
|
||||||
|
substitute for actually sending it in.
|
||||||
|
|
||||||
|
## Running it
|
||||||
|
|
||||||
|
The core app has no dependencies — stock macOS Python 3 is enough.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 /Users/user/CardGrader/app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open **http://localhost:8778**. The terminal also prints a
|
||||||
|
`192.168.x.x` address you can use from your phone on the same Wi-Fi — the
|
||||||
|
upload button works with your phone's camera directly.
|
||||||
|
|
||||||
|
Stop it with Ctrl-C. To use a different port: `PORT=9000 python3 app.py`.
|
||||||
|
|
||||||
|
All your data lives in `grades.db` in this folder — your settings and your
|
||||||
|
entire grading history, thumbnails included. Back up that one file and
|
||||||
|
you've backed up everything.
|
||||||
|
|
||||||
|
### Optional packages
|
||||||
|
|
||||||
|
Two packages unlock real functionality. Each is checked for at runtime, so
|
||||||
|
the app runs without them — you just lose that capability.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m pip install --user anthropic # required — this is what does the grading
|
||||||
|
python3 -m pip install --user pillow # the corner/edge/surface measurement pipeline
|
||||||
|
```
|
||||||
|
|
||||||
|
Without `anthropic`, grading simply doesn't work — add a key in Settings once
|
||||||
|
it's installed. Without `pillow`, grading still runs, but on the full photo
|
||||||
|
alone: no measured centering, no measured edge whitening, no magnified
|
||||||
|
close-ups. Corners and edges will come back `cannot_assess` far more often,
|
||||||
|
because a plain full-card photo genuinely doesn't show enough detail in those
|
||||||
|
regions for a model to judge them from. Install `pillow` — it's the
|
||||||
|
difference between an answer and a shrug.
|
||||||
|
|
||||||
|
### API key
|
||||||
|
|
||||||
|
Get one at [console.anthropic.com](https://console.anthropic.com), then paste
|
||||||
|
it into Settings in the app. Grading costs a few cents per card (Sonnet 5,
|
||||||
|
~$0.04-0.06 depending on how many photos you send) — nothing is charged until
|
||||||
|
you click "Estimate the grade."
|
||||||
|
|
||||||
|
## Why Sonnet 5, not a cheaper model
|
||||||
|
|
||||||
|
Settings lets you switch to Haiku 4.5 (about a third of the cost) or Opus 5
|
||||||
|
(more expensive, marginally more careful). Sonnet is the default for a
|
||||||
|
tested reason, not a hunch: run head-to-head against Haiku on the same
|
||||||
|
photos, Haiku inverted a PSA centering-tolerance comparison — read 59/41 as
|
||||||
|
*exceeding* the tolerance for a 9, when 59/41 is actually well inside it —
|
||||||
|
and the error alone dragged its estimate three grade levels below Sonnet's.
|
||||||
|
Haiku also lacks extended thinking entirely, which matters for exactly the
|
||||||
|
judgment calls this task is full of: is this a scratch or a print texture,
|
||||||
|
a reflection or real edge wear, a print line or a crease. If cost matters
|
||||||
|
enough to switch, that's a real, deliberate tradeoff — not a free lunch.
|
||||||
|
|
||||||
|
## The workflow
|
||||||
|
|
||||||
|
1. **Choose photo(s)…** — front is the minimum; add the back if you can, since
|
||||||
|
it's the only way to judge back centering, and it's what actually settles
|
||||||
|
a print-line-vs-crease call when the front alone is ambiguous.
|
||||||
|
2. **Estimate the grade.** A few seconds, a few cents.
|
||||||
|
3. Read the category table: centering, corners, edges, surface, each with a
|
||||||
|
severity and a specific observation — not just a number.
|
||||||
|
4. It's saved to **History** automatically (give it a name first if you want
|
||||||
|
to find it again later). Click any row to see the full breakdown, rename
|
||||||
|
it, or delete it.
|
||||||
|
|
||||||
|
## Installing it on a phone (and sharing it with friends)
|
||||||
|
|
||||||
|
This is a PWA, so it installs to a phone's home screen and runs full-screen
|
||||||
|
like a native app — no App Store, no Play Store, no developer account, no
|
||||||
|
$99/year, nothing to approve. It just needs to be reachable over **HTTPS**.
|
||||||
|
|
||||||
|
That HTTPS requirement is the only real hurdle, and it is not optional:
|
||||||
|
iOS and Android both refuse camera access and home-screen install over plain
|
||||||
|
`http://` on anything except `localhost`. So handing a friend your
|
||||||
|
`192.168.x.x` address will not work — it has to be a real HTTPS URL.
|
||||||
|
|
||||||
|
### Option A: a quick tunnel from your Mac
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install cloudflared
|
||||||
|
cloudflared tunnel --url http://localhost:8778
|
||||||
|
```
|
||||||
|
|
||||||
|
That prints a public `https://something.trycloudflare.com` URL, free, no
|
||||||
|
account, no card. Anyone you send it to can open it and install it. The
|
||||||
|
catch: the Mac running `app.py` has to stay awake and online the whole time
|
||||||
|
someone might want to use it, and the URL changes each time you restart the
|
||||||
|
tunnel — fine for "try this out right now", not for "usable whenever my
|
||||||
|
friends feel like it."
|
||||||
|
|
||||||
|
### Option B: run it on an always-on box you own (e.g. Unraid), behind nginx
|
||||||
|
|
||||||
|
This is the one that works when your Mac is asleep, closed, or off — the app
|
||||||
|
runs on hardware that's already always-on, with no cloud account, no card,
|
||||||
|
and no monthly fee, at a URL under a domain you actually own. The repo
|
||||||
|
includes a `Dockerfile`, `docker-compose.yml`, and an nginx config
|
||||||
|
(`nginx/card-grader.conf`) set up for exactly this.
|
||||||
|
|
||||||
|
1. **Copy the `CardGrader` folder onto the Unraid box** — e.g. into
|
||||||
|
`/mnt/user/appdata/card-grader`.
|
||||||
|
2. **Point your domain at it.** Add an `A` record for the subdomain you want
|
||||||
|
(e.g. `card-grader.yourdomain.com`) to your home network's public IP —
|
||||||
|
or, if your ISP doesn't give you a static IP, use a dynamic-DNS hostname
|
||||||
|
from your domain's DNS provider instead. Forward ports **80** and **443**
|
||||||
|
on your router to the Unraid box.
|
||||||
|
3. **Bring the app up** (over SSH on the box, or the Unraid *Compose
|
||||||
|
Manager* plugin pointed at the same folder):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
This starts `card-grader` bound only to `127.0.0.1:8778` on the host —
|
||||||
|
reachable from nginx, not directly from the LAN or internet. Data
|
||||||
|
persists to `./data/grades.db` so it survives image rebuilds.
|
||||||
|
4. **Run nginx + certbot.** If Unraid doesn't already have nginx installed
|
||||||
|
as a system service, the simplest path is a container on the host
|
||||||
|
network so it can reach `127.0.0.1:8778`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d --name nginx --network host --restart unless-stopped \
|
||||||
|
-v /mnt/user/appdata/card-grader/nginx:/etc/nginx/conf.d \
|
||||||
|
-v /mnt/user/appdata/card-grader/certbot/www:/var/www/certbot \
|
||||||
|
-v /mnt/user/appdata/card-grader/certbot/conf:/etc/letsencrypt \
|
||||||
|
nginx:alpine
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `nginx/card-grader.conf` first and replace
|
||||||
|
`card-grader.example.com` with your real subdomain. Then get a
|
||||||
|
certificate (one-time, and again every ~60 days — cron it or use
|
||||||
|
`certbot`'s built-in renewal timer):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm \
|
||||||
|
-v /mnt/user/appdata/card-grader/certbot/www:/var/www/certbot \
|
||||||
|
-v /mnt/user/appdata/card-grader/certbot/conf:/etc/letsencrypt \
|
||||||
|
certbot/certbot certonly --webroot -w /var/www/certbot \
|
||||||
|
-d card-grader.yourdomain.com
|
||||||
|
docker restart nginx
|
||||||
|
```
|
||||||
|
5. **Set your Anthropic API key** by opening `https://card-grader.yourdomain.com`
|
||||||
|
yourself first, going to Settings, and pasting it in — this only works
|
||||||
|
*before* `CARD_GRADER_LOCK=1` takes effect, so either set the key first
|
||||||
|
and then bring the lock up, or temporarily comment out that line in
|
||||||
|
`docker-compose.yml`, restart, set the key, uncomment it, and restart
|
||||||
|
again.
|
||||||
|
|
||||||
|
If Unraid already has an nginx-based reverse proxy set up (Nginx Proxy
|
||||||
|
Manager, SWAG, etc.), skip step 4 and just add a proxy host there pointing
|
||||||
|
at `127.0.0.1:8778` with the settings from `nginx/card-grader.conf` (the
|
||||||
|
body-size and read-timeout lines matter — copy those over) — those tools
|
||||||
|
handle the Let's Encrypt cert issuance for you through their own UI.
|
||||||
|
|
||||||
|
### Option C: a cloud host (Fly.io, Render)
|
||||||
|
|
||||||
|
Also works — it's a single Python file with no build step — but means
|
||||||
|
creating an account on someone else's infrastructure (and on Fly.io,
|
||||||
|
adding a card even though usage this small stays free). `grades.db` needs
|
||||||
|
a mounted persistent volume there too, same as Option B, or history resets
|
||||||
|
on every redeploy. Worth it only if you don't have an always-on box of your
|
||||||
|
own; Option B is strictly cheaper and simpler if you do.
|
||||||
|
|
||||||
|
### Installing, once it's on HTTPS
|
||||||
|
|
||||||
|
- **iPhone:** open the URL in Safari (this does not work in Chrome on iOS),
|
||||||
|
Share → **Add to Home Screen**.
|
||||||
|
- **Android:** Chrome shows an **Install app** button in the app's own header,
|
||||||
|
or use ⋮ → Install app.
|
||||||
|
|
||||||
|
### Before you hand out the URL
|
||||||
|
|
||||||
|
Anyone with the link can use it, and **grading costs money on whoever's API
|
||||||
|
key is being used**. Two things in the app deal with this:
|
||||||
|
|
||||||
|
1. **Friends can bring their own key.** Settings has a personal API key field
|
||||||
|
that lives in *their* browser and is sent with *their* requests only —
|
||||||
|
never stored on your server. If they set one, they pay for their own
|
||||||
|
grades. If they don't, they fall back to your server key and you do.
|
||||||
|
2. **Lock your settings when hosting.** Start the server with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CARD_GRADER_LOCK=1 python3 app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
which refuses all settings writes. Without it, any visitor could overwrite
|
||||||
|
your stored API key or switch the model to the most expensive one. Grading
|
||||||
|
still works normally; only the settings write is blocked.
|
||||||
|
|
||||||
|
Your stored API key is never sent to any browser regardless — the settings
|
||||||
|
endpoint returns only a boolean saying whether one is configured.
|
||||||
|
|
||||||
|
Everyone shares one `grades.db`, so the History list is communal. That's
|
||||||
|
usually fine among friends; if it isn't, run separate instances.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `app.py` | HTTP server and JSON API. Run this. |
|
||||||
|
| `store.py` | SQLite: settings + grading history |
|
||||||
|
| `vision.py` | The Claude vision call — prompts, schemas, cost estimation |
|
||||||
|
| `cardimage.py` | The measurement pipeline: card detection, corner/edge/surface crops, centering and edge-whitening measurement |
|
||||||
|
| `static/` | The web UI |
|
||||||
|
| `static/sw.js` | Service worker — makes it installable; served from `/sw.js` so its scope covers the whole app |
|
||||||
|
| `grades.db` | Your data (created on first run) |
|
||||||
|
| `Dockerfile` | Container image for Option B/C deployment |
|
||||||
|
| `docker-compose.yml` | Runs the container with a persistent `./data` volume |
|
||||||
|
| `nginx/card-grader.conf` | Reverse-proxy + Let's Encrypt config for Option B |
|
||||||
|
|
||||||
|
## If something breaks
|
||||||
|
|
||||||
|
Port already in use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lsof -ti :8778 | xargs kill
|
||||||
|
```
|
||||||
|
|
||||||
|
The "Install app" button never appears, or the app opens with a browser
|
||||||
|
address bar: you're on `http://`, not `https://`. Browsers only allow install
|
||||||
|
and camera access on a secure origin — see the tunnel section above.
|
||||||
|
`localhost` is the one exception, which is why it works on your own Mac.
|
||||||
|
|
||||||
|
## Where this came from
|
||||||
|
|
||||||
|
Extracted from a personal Pokemon card flip-tracking app where grading
|
||||||
|
started as one feature among many (inventory, price lookups, an eBay lot
|
||||||
|
evaluator). The grading pipeline turned out to be the most generally useful
|
||||||
|
part — it doesn't care what card game something is from — so it's its own
|
||||||
|
project now. If you want the original's inventory/pricing features too,
|
||||||
|
that's a separate app; this one only grades.
|
||||||
410
app.py
Normal file
410
app.py
Normal file
|
|
@ -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()
|
||||||
802
cardimage.py
Normal file
802
cardimage.py
Normal file
|
|
@ -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)
|
||||||
19
docker-compose.yml
Normal file
19
docker-compose.yml
Normal file
|
|
@ -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
|
||||||
43
nginx/card-grader.conf
Normal file
43
nginx/card-grader.conf
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
572
static/app.js
Normal file
572
static/app.js
Normal file
|
|
@ -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, '>')
|
||||||
|
.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 = `<div class="hint">measured whitening — ${esc(measuredLine)}</div>`;
|
||||||
|
} else if (key === 'centering' && centeringLine) {
|
||||||
|
extra = `<div class="hint">${esc(centeringLine)}</div>`;
|
||||||
|
}
|
||||||
|
return `<tr>
|
||||||
|
<td style="text-transform:capitalize">${key}</td>
|
||||||
|
<td><span class="pill ${SEVERITY_PILL[sev]}">${esc(SEVERITY_LABEL[sev])}</span></td>
|
||||||
|
<td class="cardcell-meta">${esc(cat.observation || '')}${extra}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="note">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.</div>
|
||||||
|
<div style="margin:14px 0">${renderSlab(g)}</div>
|
||||||
|
<div class="table-scroll"><table class="grid"><tbody>${rows}</tbody></table></div>
|
||||||
|
${(g.limitations || []).length ? `<div style="margin-top:10px"><strong>Couldn't check:</strong>
|
||||||
|
<ul>${g.limitations.map((l) => `<li>${esc(l)}</li>`).join('')}</ul></div>` : ''}
|
||||||
|
${g.note ? `<p class="cardcell-meta">${esc(g.note)}</p>` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 `
|
||||||
|
<div class="slab ${slabClass(grade)}">
|
||||||
|
<div class="slab-main">
|
||||||
|
${g.card_type ? `<div><span class="card-type-tag">${esc(TYPE_LABEL[g.card_type] || 'Card')}</span></div>` : ''}
|
||||||
|
<div class="slab-title">${esc(title)}</div>
|
||||||
|
<div class="slab-sub">${esc(bits)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="slab-grade">
|
||||||
|
<span class="n">${grade === null || grade === undefined ? '—' : grade}</span>
|
||||||
|
<span class="word">${esc(GRADE_WORD[grade] || 'estimate')}</span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGradeReview() {
|
||||||
|
const el = $('#grade-review');
|
||||||
|
if (!gradeState.files.length && !gradeState.result) { el.innerHTML = ''; return; }
|
||||||
|
|
||||||
|
if (!gradeState.result) {
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="panel" style="margin-top:14px;background:var(--plane)">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>${gradeState.files.length} photo(s) ready</h2>
|
||||||
|
<span class="hint">more angles = a tighter estimate</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap">
|
||||||
|
${gradeState.files.map((f) =>
|
||||||
|
`<img src="${f.dataUrl}" alt="" style="height:110px;border-radius:8px">`).join('')}
|
||||||
|
<div style="display:flex;flex-direction:column;gap:8px">
|
||||||
|
<button class="btn btn-quiet btn-sm" id="grade-add-more">+ Add another photo</button>
|
||||||
|
<button class="btn" id="grade-run"${gradeState.busy ? ' disabled' : ''}>
|
||||||
|
${gradeState.busy ? 'Grading…' : 'Estimate the grade'}</button>
|
||||||
|
<button class="btn btn-quiet btn-sm" id="grade-cancel">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const g = gradeState.result.grade;
|
||||||
|
const cost = gradeState.result.grade.estimated_cost;
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="panel" style="margin-top:14px;background:var(--plane)">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>Grade estimate</h2>
|
||||||
|
<span class="hint">${cost ? `about $${cost.toFixed(3)}` : ''}</span>
|
||||||
|
</div>
|
||||||
|
${renderGradeBlock(g)}
|
||||||
|
<div class="drawer-actions">
|
||||||
|
<button class="btn btn-quiet" id="grade-discard">Done</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => `
|
||||||
|
<tr data-history-row="${g.id}">
|
||||||
|
<td>
|
||||||
|
<div class="cardcell">
|
||||||
|
${g.thumbnail ? `<img src="${g.thumbnail}" alt="">` : '<span class="thumb-blank"></span>'}
|
||||||
|
<div>
|
||||||
|
<div class="cardcell-name">${esc(g.label || g.card_note || 'Untitled card')}</div>
|
||||||
|
<div class="cardcell-meta">${g.card_type ? `${esc(TYPE_LABEL[g.card_type] || 'Card')} · ` : ''}${g.image_count || 1} photo(s)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><span class="pill ${gradePillClass(g.estimated_grade)}">${
|
||||||
|
g.estimated_grade === null ? 'n/a' : `PSA ${g.estimated_grade}`}</span></td>
|
||||||
|
<td class="cardcell-meta">${esc(g.confidence || '')}</td>
|
||||||
|
<td class="cardcell-meta">${esc(fmtWhen(g.created_at))}</td>
|
||||||
|
<td class="num"><button class="btn btn-quiet btn-sm" data-history-delete="${g.id}">Delete</button></td>
|
||||||
|
</tr>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEntryModal(g) {
|
||||||
|
$('#entry-modal').innerHTML = `
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>${esc(g.label || g.card_note || 'Untitled card')}</h2>
|
||||||
|
<button class="close" id="entry-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="cardcell-meta" style="margin-bottom:10px">${esc(fmtWhen(g.created_at))} ·
|
||||||
|
${g.usage && g.usage.model ? esc(g.usage.model) : ''}</div>
|
||||||
|
${renderGradeBlock(g)}
|
||||||
|
<div class="drawer-actions">
|
||||||
|
<input class="input" id="entry-label" placeholder="Rename this card…" value="${esc(g.label || '')}" style="flex:1 1 200px">
|
||||||
|
<button class="btn btn-quiet btn-sm" id="entry-save-label">Save name</button>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="btn btn-danger btn-sm" data-history-delete="${g.id}">Delete</button>
|
||||||
|
</div>`;
|
||||||
|
$('#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]) =>
|
||||||
|
`<option value="${id}" ${s.vision_model === id ? 'selected' : ''}>
|
||||||
|
${esc(info.label)} — ~$${info.per_grade.toFixed(3)}/grade</option>`).join('');
|
||||||
|
|
||||||
|
const locked = s.settings_locked;
|
||||||
|
|
||||||
|
$('#settings-modal').innerHTML = `
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>Settings</h2>
|
||||||
|
<button class="close" id="settings-close">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Your API key</h3>
|
||||||
|
<div class="fields">
|
||||||
|
<div class="field wide">
|
||||||
|
<label>Anthropic API key (this browser only)</label>
|
||||||
|
<input class="input" id="set-my-key" type="password" value="${esc(myApiKey() || '')}"
|
||||||
|
placeholder="sk-ant-...">
|
||||||
|
<span class="suffix">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.'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Server settings${locked ? ' <span class="pill pill-raw">locked</span>' : ''}</h3>
|
||||||
|
${locked ? `<div class="note">This server is shared, so its settings are read-only.
|
||||||
|
Use your own key above.</div>` : `
|
||||||
|
<div class="fields">
|
||||||
|
<div class="field wide">
|
||||||
|
<label>Server API key (used when a visitor has none)</label>
|
||||||
|
<input class="input" id="set-api-key" type="password"
|
||||||
|
placeholder="${s.server_key_configured ? '•••••••• already set — type to replace' : 'sk-ant-...'}">
|
||||||
|
<span class="suffix">Never sent back to the browser once saved. Leave blank to keep
|
||||||
|
the current one.</span>
|
||||||
|
</div>
|
||||||
|
<div class="field wide">
|
||||||
|
<label>Vision model</label>
|
||||||
|
<select id="set-model">${modelOptions}</select>
|
||||||
|
<span class="suffix">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.</span>
|
||||||
|
</div>
|
||||||
|
</div>`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="drawer-actions">
|
||||||
|
<button class="btn" id="settings-save">Save</button>
|
||||||
|
</div>`;
|
||||||
|
$('#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;
|
||||||
|
});
|
||||||
BIN
static/icon-180.png
Normal file
BIN
static/icon-180.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 981 B |
BIN
static/icon-192.png
Normal file
BIN
static/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1 KiB |
BIN
static/icon-512-maskable.png
Normal file
BIN
static/icon-512-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
BIN
static/icon-512.png
Normal file
BIN
static/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3 KiB |
6
static/icon.svg
Normal file
6
static/icon.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||||
|
<rect width="128" height="128" rx="24" fill="#16224a"/>
|
||||||
|
<rect x="34" y="24" width="60" height="80" rx="8" fill="#2f6fd0" stroke="#35c6a8" stroke-width="3"/>
|
||||||
|
<path d="M48 66 L60 78 L84 50" fill="none" stroke="#ffffff" stroke-width="7"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 367 B |
90
static/index.html
Normal file
90
static/index.html
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en" data-base="__BASE__">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Card Grader</title>
|
||||||
|
<link rel="manifest" href="__BASE__/static/manifest.json">
|
||||||
|
<link rel="icon" href="__BASE__/static/icon.svg">
|
||||||
|
<meta name="theme-color" content="#16224a">
|
||||||
|
<!-- iOS ignores the web app manifest for install behaviour and needs its own
|
||||||
|
meta tags. Without these, "Add to Home Screen" on an iPhone produces a
|
||||||
|
bookmark that opens in Safari with the address bar showing, rather than
|
||||||
|
something that looks and behaves like an installed app. -->
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Card Grader">
|
||||||
|
<link rel="apple-touch-icon" href="__BASE__/static/icon-180.png">
|
||||||
|
<link rel="stylesheet" href="__BASE__/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">
|
||||||
|
<span class="brand-mark"></span>
|
||||||
|
<h1>Card Grader</h1>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<button id="btn-install" class="btn" hidden>Install app</button>
|
||||||
|
<button id="btn-settings" class="btn btn-quiet">Settings</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="banner" class="banner" hidden></div>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
|
||||||
|
<!-- ------------------------------------------------------------- grade -->
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>Grade a card</h2>
|
||||||
|
<span class="hint" id="model-cost-hint"></span>
|
||||||
|
</div>
|
||||||
|
<div class="note">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.</div>
|
||||||
|
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:12px">
|
||||||
|
<button id="btn-grade" class="btn">Choose photo(s)…</button>
|
||||||
|
<input id="grade-label" class="input" placeholder="Card name (optional, for your history)" style="flex:1 1 220px">
|
||||||
|
</div>
|
||||||
|
<input id="grade-file" type="file" accept="image/*" multiple hidden>
|
||||||
|
<div id="grade-status" class="search-status" hidden></div>
|
||||||
|
<div id="grade-review"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ----------------------------------------------------------- history -->
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>History</h2>
|
||||||
|
<span class="hint" id="history-count"></span>
|
||||||
|
</div>
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table class="grid" id="history-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="col-card">Card</th>
|
||||||
|
<th>Grade</th>
|
||||||
|
<th>Confidence</th>
|
||||||
|
<th>When</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="history-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p id="history-empty" class="empty" hidden>Nothing graded yet.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- ----------------------------------------------------------- settings -->
|
||||||
|
<div id="modal-scrim" class="scrim" hidden></div>
|
||||||
|
<div id="settings-modal" class="modal" hidden role="dialog" aria-label="Settings"></div>
|
||||||
|
|
||||||
|
<!-- ------------------------------------------------------- history entry -->
|
||||||
|
<div id="entry-scrim" class="scrim" hidden></div>
|
||||||
|
<div id="entry-modal" class="modal" hidden role="dialog" aria-label="Grade detail"></div>
|
||||||
|
|
||||||
|
<script src="__BASE__/static/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
17
static/manifest.json
Normal file
17
static/manifest.json
Normal file
|
|
@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
367
static/style.css
Normal file
367
static/style.css
Normal file
|
|
@ -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; }
|
||||||
|
}
|
||||||
62
static/sw.js
Normal file
62
static/sw.js
Normal file
|
|
@ -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}/`)))
|
||||||
|
);
|
||||||
|
});
|
||||||
202
store.py
Normal file
202
store.py
Normal file
|
|
@ -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()
|
||||||
719
vision.py
Normal file
719
vision.py
Normal file
|
|
@ -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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue