Compare commits

...

10 commits

Author SHA1 Message Date
Barely Removable
bc6ee2cf27 Add CLAUDE.md documenting project architecture and deployment
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 16:40:24 -07:00
Barely Removable
22bb14e5d9 Detect the card INSIDE a holder, not the holder's outline
_detect_card_box works from 'what isn't background', so on a card shot in a
toploader, sleeve or slab it finds the HOLDER — the card is never the
outermost non-background thing. Every downstream crop was then cut against
plastic instead of cardstock.

Per-side edge detection can't disambiguate: a holder's rim is as strong a
step as the card's cut, and on a card sitting off-centre in its holder the
two land at different insets per side (verified on the Jordan: card's left
edge at 75px, top edge at ~40px, holder rim at ~34px). What separates them
is that a CARD has known proportions and a holder does not -- so this
searches combinations of candidate edges and keeps whichever rectangle best
matches a real card. Jordan 7.0% -> 0.3% off standard; Ohtani 5.8% -> 0.4%.
Verified visually: both crops are the bare card, holder gone.

Conservative by construction -- the inner box must land within 2.5% of a
standard ratio AND beat the outer by 2.5 points. A correctly-detected bare
card already sits near 0%, so nothing inside it can clear that bar and the
refinement declines. Confirmed against six normal-photo variants (tight
margin, large margin, dark background, die-cut, near-square): none refined.

Two consequences handled rather than papered over:

- aspect_profile deliberately keeps using the RAW box. The refinement picks
  the rectangle that best matches a standard ratio, so measuring that and
  asking 'is this a standard ratio?' is circular and would clear a genuinely
  trimmed card. It now refuses on holdered cards instead.

- edge_wear_profile refuses on holdered cards even though the card is now
  located. Whitening is detected as the outermost band reading lighter than
  the one inside it, and a holder's inner edge sits exactly there: measured
  58%/53% 'whitening' on two Jordan edges the model called clean reading the
  same strips. Centering does NOT refuse -- it compares border widths, which
  clear plastic doesn't distort (Ohtani now reads 51/49, having previously
  refused outright).
2026-08-25 08:42:53 -07:00
Barely Removable
3510f637ba Stop telling the model to give up on corners/edges just for being in a holder
The framing caveat added earlier told the model to find the card's real
edge inside each mis-cut crop, then immediately offered 'say cannot_assess
if the close-up doesn't clearly contain it' — wrapped in alarming language
about cases. On a Fleer Ultra Jordan shot in a clear toploader it took the
escape hatch for corners AND edges, returning three of four categories as
'can't tell from photo' on a card whose corners are plainly visible through
the plastic. That was a regression I introduced, not a limit of the photo.

A toploader is transparent: corner sharpness, corner whitening, edge
chipping and edge whitening all read through it, and the card's cut line is
visible as a distinct boundary inside the holder's (confirmed in the pixel
profile — the card's own edge shows as a clear step ~75px inside the
detected holder boundary). Those categories now get judged normally, with
cannot_assess reserved for genuinely hidden geometry.

Surface keeps the escape hatch, because there it's correct: scratches, dust
and glare on a holder sit directly over the card's surface and can't be
separated from it in a photo.
2026-08-25 08:30:39 -07:00
Barely Removable
811c16f334 Correct PSA 10 centering tolerance; encode the 5% leeway rule properly
Verified the encoded rubric against psacard.com/gradingstandards directly
(the page 403s automated fetchers, so read via a real browser session,
clicking through all 14 grade-definition slides).

The app said '55/45 to 60/40 allows a 10'. PSA publishes a flat 55/45 for
a 10 -- and the app's very next line already said 60/40 allows a 9, so the
range was internally contradictory as well as over-lenient: a 58/42 card
would read as 10-eligible when PSA would cap it at 9.

The range appears to have been a garbled memory of a real rule the app
otherwise didn't encode at all: PSA grants a 5% leeway on front centering
minimums for cards grading 7 or better. The old text applied that leeway to
the 10 only and to no other grade. Now the published tolerances stand as
written and the leeway is stated once, for the grades it actually covers,
as grader discretion keyed to eye appeal rather than a hard second
tolerance -- so a borderline card gets a range instead of a confident
single grade.

Everything else checked out exactly: 9=60/40, 8=65/35, 7=70/30, 6=80/20,
5/4=85/15, 3/2=90/10, and back 75/25 for a 10 / 90/10 from 9 down.
PSA's 'Altered Authentic' designation also confirms the trimming guidance
added earlier (alterations -> no numeric grade).
2026-08-25 08:19:00 -07:00
Barely Removable
0cbeb26665 Audit round 2: aspect measured the case too; defensive image decode; own-key state reset
4. aspect_profile had the same root cause as edges/centering but no guard.
   On the cased photo it reported 5.8% off standard -- the CASE's
   proportions -- and the UI would surface that as a trimming hint about a
   card it never measured. This one is more directly load-bearing than the
   other two, since the measurement IS the detected box. Now refuses, with
   both consumers (prompt block and UI hint) checking reliable.

5. _decode_images could KeyError/raise out of a plain read on a malformed
   row, and separately b64decode returns b'' rather than raising on some
   corrupt input -- which would have fed a zero-byte 'photo' into grading
   to fail confusingly deep in the pipeline. Both now degrade to the
   existing re-pick path.

6. _last_used_own_key is instance state on a handler that serves every
   request on a keep-alive connection, so it outlives the request that set
   it. Currently safe (every logging path assigns first), but a future path
   that logged without reaching the assignment would bill the previous
   request's payer. Cleared up-front now.

Full regression suite re-run: die-cut exclusion, uniform cards, centering,
aspect, cased-photo refusals, and the store layer all still behave.
2026-08-25 08:05:33 -07:00
Barely Removable
6c612c1f4a Audit fixes: mis-framed crops, GPT cost 2.2x low, camera button losing photos
1. detail_crops had no equivalent of the margin guard the measurements got.
   When box detection fails (card still in a case), every crop is cut
   relative to the wrong rectangle -- verified the 'TOP-LEFT CORNER'
   close-up of the cased Ohtani is actually the CASE's corner bracket. The
   measurements refuse and explain; the crops kept being produced and
   captioned authoritatively, and the prompt tells the model to judge
   corners/edges/surface *from* them. Now surfaces a framing caveat telling
   the model to locate the real card edge inside each crop and say
   cannot_assess rather than grade the holder.

2. gpt-5.6-sol's approx_image_tokens was a pre-launch guess (1500) that
   advertised ~2.2x under true cost across all 9 real calls. Recalibrated
   to 3560 against median real usage, and added a per-model output estimate
   since GPT writes ~1.2k tokens of verdict vs Sonnet's ~0.9k. Both models
   now advertise within ~3% of observed cost.

3. 'Take photo' didn't reset after a completed grade, unlike 'Choose from
   library'. The result view has no photo strip, so new photos piled up
   invisibly behind the old verdict, silently, to the 6-photo cap. Also
   fixed the cap itself being a silent no-op with no explanation.
2026-08-25 08:00:48 -07:00
Barely Removable
deabe74593 Replace History-row authenticity dot with a visible badge
A 8px dot next to the card name only rewards someone who already suspects
a card and goes looking for it -- the badge needs to be the thing that
catches your eye scanning the home-screen list itself. Same red/amber
tiers as the detail-view banner, using the existing .pill shape so it sits
naturally next to the photo-count meta line rather than needing new
layout.
2026-08-23 11:55:29 -07:00
Barely Removable
3cacf2f292 Cap self-reported 'high' confidence when neither centering nor edge whitening was actually measured
Confidence was entirely the model's own self-report, based only on photo
sharpness and category coverage -- it had no reliable way to account for
whether centering/edge whitening came from a real pixel measurement or from
its own eye, even though the app treats that distinction as a big deal
everywhere else (it's the whole reason cardimage.py exists). A sharp,
well-lit photo of a foil or die-cut card could claim 'high' while actually
running on eye-only judgment for two of PSA's four categories.

Enforced in code rather than left to the prompt alone -- this is an
objective, checkable fact (did centering_profile/edge_wear_profile return a
reliable result or not), exactly the kind of thing that shouldn't depend on
the model correctly weighing one more instruction among many. Only caps
high->medium when NEITHER measurement is available; one of two still
measured is left to the model's own judgment, since that's a real
legitimate basis for confidence. Also updated the prompt itself so the
model's own reasoning stays consistent with what gets displayed, rather
than silently contradicting its own prose.

Tested all five branches (neither/both/one/one-unreliable measured, and
confirmed no spurious limitation text when the model already said
medium).
2026-08-23 11:43:36 -07:00
Barely Removable
2b3bbaf40f Add a structured authenticity signal with a distinct banner, instead of burying it in card_note
The Charizard grade that prompted this had the model writing 'possible
non-standard/proxy print' directly into card_note -- the only free-text
field available -- since there was nowhere else for that observation to go.
That's why it got no visual treatment: the UI renders card_note as a plain
grey subtitle, identical to any ordinary card name.

Added authenticity as its own top-level field (flag: none/worth_checking/
likely_not_genuine, plus an observation) alongside the four grading
categories but explicitly separate from them -- this is about whether the
card is a genuine product at all, not its condition, and PSA authenticates
before it grades, so a flagged card's estimated_grade goes to null rather
than a number.

Prompted with concrete tells for both classes of concern: fan-made/proxy
prints (fictional sets, non-existent number combos, home-printer texture,
'proxy' watermarks) and counterfeits of real cards (colour/font/holo
mismatches against genuine copies). Trimming (already covered under CORNERS
from an earlier fix) now also sets this field when flagged, so the UI has
one place to check regardless of which specific issue triggered it.

UI: a full banner (not a hint line) above the slab -- critical red for
likely_not_genuine, warning amber for worth_checking -- plus a matching dot
next to the card name in History so it's visible without opening the card.
Both colours reuse --critical/--warning rather than the brand red, since
this is exactly the 'distrust this' signal those are already reserved for.

Migration tested against the live schema shape; existing rows get
authenticity=None and render with no banner, as before.
2026-08-23 08:19:58 -07:00
Barely Removable
d026cce91a Refuse edge/centering measurement when box detection found no real margin
A card photographed still inside a black display case exposed this: the
case filled the entire frame with no background anywhere, so
_detect_card_box returned a box at essentially zero margin from the photo's
own edges (0px left, 1px right on a 986px-wide photo). Every downstream
measurement band -- a few percent of the card's own short dimension, by
design, since real wear lives in the outermost sliver -- then sampled
entirely within the case's embossed plastic texture, never reaching the
actual card. That texture read as 98.9% edge whitening on two sides.

The per-edge material-consistency check added for the earlier die-cut fix
didn't catch this: the case is uniformly dark on all four sides at the
shallow sampling depth used, so no edge disagreed with the others -- the
false signal came from local texture noise within one uniform (wrong)
material, not a mismatch between materials. This needed a different,
earlier check: whether box detection could plausibly have found the real
card boundary at all, gated on margin as a fraction of the photo before any
per-edge analysis runs.

Also fixed a related bug in centering_profile's existing die-cut check: it
compared each side against the median of the other three and returned on
the FIRST hit, so a genuine 2-vs-2 split (both left and right reading the
case, both top and bottom reading the real card) blamed a single side and
never even examined whether the second was equally wrong. Replaced with a
best-single-exclusion search that correctly distinguishes a true one-side
outlier from an unexplainable split.

Verified against the actual photo that exposed this (card in a scalloped
black case) -- both now correctly refuse instead of measuring the case.
Regression-tested: normal photos with reasonable margin, and the earlier
die-cut single-outlier case, are both unaffected.
2026-08-23 08:05:51 -07:00
7 changed files with 828 additions and 65 deletions

139
CLAUDE.md Normal file
View file

@ -0,0 +1,139 @@
# Card Grader
Self-hosted web app that estimates PSA trading card grades from photos, using
vision-capable LLMs (Claude and/or GPT) as the grading engine. Runs as a
single Docker container on the user's Unraid home server, exposed at
`hippofam.com/cards` through Nginx Proxy Manager.
No framework: the whole backend is Python stdlib
(`http.server.ThreadingHTTPServer`) plus `sqlite3`. Optional third-party
packages are `anthropic`, `openai`, and `Pillow` (image processing only —
never required for the server to boot).
## Architecture
- **`app.py`** — HTTP routing/handlers. Reads Basic Auth username forwarded
by the reverse proxy for *attribution only* (who graded what), never for
access control — auth itself is enforced by Nginx Proxy Manager in front
of the container via htpasswd. Admin-only settings are gated by
`ADMIN_USERNAME` (`CARD_GRADER_ADMIN_USER` env var). Request bodies are
capped (`MAX_BODY_BYTES`); oversized/malformed bodies get a 413 and the
connection is closed rather than trusting a client-supplied length.
- **`vision.py`** — Provider abstraction over Anthropic and OpenAI vision
APIs (`_call_anthropic` / `_call_openai`, dispatched from a common
entrypoint). `MODELS` holds per-model pricing/capabilities.
`GRADING_SYSTEM` is the large system prompt encoding PSA's actual grading
rubric (centering tolerances, corner/edge/surface criteria, qualifiers —
MC/OC/PD/ST/MK, half-grades, Altered Authentic) — verified against
psacard.com, beckett.com, and cgccards.com, not guessed. Self-reported
"high" confidence is capped to "medium" when neither centering nor edge
whitening was independently measurable from the image geometry.
- **`cardimage.py`** — Pillow-based image pipeline: card boundary detection,
corner/edge/surface crop generation, centering/edge-whitening/aspect-ratio
measurement from pixels (not just left to the model's eye). Includes
holder/toploader-aware detection (`detect_card_box`): a combinatorial
search over candidate edge positions constrained to standard card aspect
ratios, so the app can locate the actual card inside a slab/toploader
rather than just detecting the holder's outline. When a holder is
detected, edge-whitening measurement is refused (the holder's own inner
edge corrupts that specific measurement) but centering still runs
(border-width geometry, unaffected by clear plastic), and aspect-ratio
measurement deliberately uses the *unrefined* box to avoid circularity
(refinement selects for standard-ratio rectangles, so measuring the
refined box's ratio would trivially always look "normal").
- **`store.py`** — SQLite (WAL mode) persistence. `grades` and
`grade_events` tables, JSON columns for structured data, schema
migrations via idempotent `ALTER TABLE` in `store.init()`. List/detail
API responses explicitly exclude `source_images_json` to keep payloads
small. DB path overridable via `CARD_GRADER_DB_PATH`.
- **`static/`** — Vanilla JS/CSS/HTML frontend, PWA-enabled (manifest,
service worker). "PSA slab" visual theme. `__BASE__` is templated at
request time so the app works correctly when served from a sub-path
(`/cards`) behind the proxy.
## Local development
```bash
python3 app.py
```
Needs `ANTHROPIC_API_KEY` and/or `OPENAI_API_KEY` in the environment to
actually grade; the server itself has no required third-party deps. A local
`grades.db` is gitignored and is scratch/test data only — it is **not** the
production database (that lives on the server, see below).
## Deployment
**Target:** Unraid server, container path `/mnt/user/appdata/card-grader`,
built from `Dockerfile` / `docker-compose.yml` in this repo. Non-root
container user (`99:100` / `nobody:users`), healthcheck, log rotation,
`init: true`. Reverse-proxied by Nginx Proxy Manager at `hippofam.com/cards`
with per-user HTTP Basic Auth.
**Current deploy mechanism (as of 2026-08-25): manual.** Changes are copied
to the server by hand (`scp`) and the container is rebuilt over SSH; the two
copies of the repo (local Mac, server) are kept in sync by committing on
both sides. There is currently no automated push-to-deploy path connected —
see below.
**Pending: restricted git-push deploy.** A setup script
(`setup-card-grader-deploy.sh`, delivered to the user, not yet run) creates
a scoped `carddeploy` user on the Unraid box for exactly this purpose:
- Login shell is `git-shell` — accepts git push/pull only, nothing else (no
interactive shell, no arbitrary commands, no SFTP).
- A `post-receive` hook (root-owned, mode 755 — not writable by the
`carddeploy` account, and not something `git push` can overwrite via the
protocol regardless) triggers a single root-owned deploy script via one
narrowly-scoped `sudo` rule (`NOPASSWD` for that *exact script path*
only — deliberately never raw `docker`/the `docker` group, both of which
are root-equivalent on the whole box via the daemon socket).
- The deploy script does `git checkout -f main` into the live app directory
and `docker compose up -d --build`.
- State (the bare repo, the deploy script, the sudoers source file) lives
under `/mnt/user/appdata/card-grader-deploy/` on the array — Unraid boots
from USB into RAM, so anything living only under `/` would vanish on
reboot. Setup is reinstalled idempotently via `/boot/config/go` (Unraid's
official boot-time persistence hook) so it survives reboots.
Once the user runs that script and confirms, the local SSH config alias
`unraid-cardgrader` (in `~/.ssh/config`, already pointed at the new
`carddeploy` user and a fresh dedicated keypair
`~/.ssh/id_ed25519_cardgrader_deploy`) will be usable, and the remaining
step is adding the bare repo as a git remote here and doing a first push to
verify the pipeline end-to-end. **Do not assume this pipeline is live**
confirm with the user before relying on it.
A prior root SSH key/access to the whole box was explicitly revoked by the
user; the replacement above is intentionally scoped to *only* updating this
one container, not general Unraid/Docker access.
## Conventions and constraints established for this project
- PSA grading rules must be verified against a real source (PSA's own site,
Beckett, CGC) before being encoded into `GRADING_SYSTEM` — this rubric has
had real accuracy bugs (e.g. a self-contradictory centering tolerance, a
missing 5% front-centering leeway rule for grades 7+) found and fixed
through actual verification, not assumption.
- The vision prompt's `cannot_assess` escape hatch is deliberately narrow:
surface condition genuinely can't be separated from dust/scratches on a
toploader/slab's plastic, so it keeps the escape hatch. Corner and edge
geometry (sharpness, whitening, chipping) **does** read through clear
plastic and must be judged normally — an earlier prompt version gave the
model an escape hatch for corners/edges too, which caused false
"cannot tell from photo" results on cards that were plainly visible
through the holder. Don't reintroduce that.
- No pre-flight/"precheck" step before a paid grading call. This was tried
and explicitly rejected by the user — their workflow is screenshots that
can't be pulled out of the toploader, so a pre-check step doesn't fit and
was reverted.
- No browser automation (Playwright or otherwise) to scrape eBay listings.
Tried a plain `curl`-based fetch once (not Playwright) and got an
immediate 403; iterating on headers/fingerprinting to get past that would
be bot-detection evasion regardless of which tool performs it, and is
out of scope. This feature is paused; if revisited, the legitimate path
is a client-side bookmarklet/extension that uses the *user's own*
authenticated browser session rather than a server-side fetch.
- Never trust the reverse proxy's forwarded auth header for anything beyond
attribution (whose name to log against a grade) — access control is the
proxy's job, not the app's.

7
app.py
View file

@ -432,6 +432,12 @@ class Handler(BaseHTTPRequestHandler):
# 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
# Cleared up-front, not just assigned on success. One handler
# instance serves every request on a keep-alive connection, so this
# attribute outlives the request that set it — without the reset, a
# future path that logs an event without reaching the assignment
# below would silently bill the previous request's payer.
self._last_used_own_key = False
print("[grade] calling vision model={} on {} image(s){}".format(
model, len(images), " (caller key)" if caller_key else ""), flush=True)
@ -453,6 +459,7 @@ class Handler(BaseHTTPRequestHandler):
"closeups": result["closeups"],
"card_type": result["card_type"],
"card_note": result["card_note"],
"authenticity": result["authenticity"],
"edge_measurements": result["edge_measurements"],
"centering_measurement": result["centering_measurement"],
"aspect_measurement": result["aspect_measurement"],

View file

@ -241,6 +241,213 @@ def _detect_card_box(img):
return box
# A holder is only a little larger than the card it holds, so the card's
# edge is always within this fraction of the detected box. Searching deeper
# would start finding the card's own printed border and inner artwork.
_INNER_SEARCH_FRACTION = 0.15
# Candidate edges per side. More costs nothing (the combination search is
# tiny) but risks admitting weak noise peaks as candidates.
_INNER_CANDIDATES_PER_SIDE = 5
# The inner box must land this close to a real card's proportions...
_INNER_MAX_DEVIATION = 2.5
# ...AND beat the outer box by at least this much. Together these are what
# keeps a normal, correctly-detected card from being "refined" into its own
# artwork: a good outer box already sits near 0% deviation, so nothing
# inside it can improve by 2.5 points, and the refinement declines.
_INNER_MIN_IMPROVEMENT = 2.5
def _aspect_deviation(w, h):
"""Percent off the nearest standard card proportion."""
ratio = min(w, h) / float(max(w, h))
return min(abs(ratio - s) / s * 100.0 for s in STANDARD_ASPECT_RATIOS.values())
def _edge_candidates(profile, limit):
"""Strongest steps in a 1-D mean profile, nearby duplicates suppressed."""
grad = sorted(((d, abs(profile[d + 1] - profile[d]))
for d in range(len(profile) - 1)),
key=lambda t: -t[1])
out = []
for d, v in grad:
if v < 8: # below this is texture, not a boundary
break
if all(abs(d - o) > 6 for o, _ in out):
out.append((d, v))
if len(out) >= limit:
break
return out
def _refine_to_inner_card(img, box):
"""Find the card INSIDE a holder. Returns (box, refined?).
_detect_card_box works from "what isn't background", which on a card
photographed inside a toploader, sleeve or slab finds the HOLDER's
outline the card is never the outermost non-background thing. Every
downstream crop is then cut against the plastic instead of the card,
which is what made a Fleer Ultra Jordan's corners and edges unreadable
despite being plainly visible through clear plastic.
Per-side edge detection alone can't fix it: a holder's own rim is just
as strong a step as the card's cut, and on a card sitting off-centre in
its holder the two are at different insets on different sides. What
disambiguates them is that a CARD has known proportions and a holder
does not so this searches combinations of candidate edges and keeps
the rectangle that best matches a real card, rather than trusting any
single side. On the Jordan that moved a 7.0%-off box to 0.3%-off.
Deliberately conservative: it must both land very close to a standard
ratio and clearly beat the box it started from, or the original stands.
"""
if Image is None:
return box, False
try:
crop = img.crop(box).convert("L")
w, h = crop.size
if w < 60 or h < 60:
return box, False
px = crop.load()
# Sample the middle half of each axis: the ends run through the
# card's rounded corners and the holder's, which blur the step.
def col_mean(x):
lo, hi = int(h * 0.25), int(h * 0.75)
return sum(px[x, y] for y in range(lo, hi)) / float(hi - lo)
def row_mean(y):
lo, hi = int(w * 0.25), int(w * 0.75)
return sum(px[x, y] for x in range(lo, hi)) / float(hi - lo)
nx = max(4, int(w * _INNER_SEARCH_FRACTION))
ny = max(4, int(h * _INNER_SEARCH_FRACTION))
per = _INNER_CANDIDATES_PER_SIDE
# Zero is always a candidate: a side may need no adjustment at all.
cl = _edge_candidates([col_mean(d) for d in range(nx)], per) + [(0, 0)]
cr = _edge_candidates([col_mean(w - 1 - d) for d in range(nx)], per) + [(0, 0)]
ct = _edge_candidates([row_mean(d) for d in range(ny)], per) + [(0, 0)]
cb = _edge_candidates([row_mean(h - 1 - d) for d in range(ny)], per) + [(0, 0)]
outer_dev = _aspect_deviation(w, h)
best = None
for l, vl in cl:
for r, vr in cr:
for t, vt in ct:
for b, vb in cb:
iw, ih = w - l - r, h - t - b
# A card fills most of its holder; a big shrink means
# this latched onto artwork, not a cut line.
if iw < w * 0.7 or ih < h * 0.7:
continue
dev = _aspect_deviation(iw, ih)
strength = (vl + vr + vt + vb) / 4.0
# Aspect dominates; edge strength only breaks ties
# between rectangles that fit a card equally well.
score = dev - strength * 0.05
if best is None or score < best[0]:
best = (score, dev, (l, t, r, b))
if best is None:
return box, False
_, dev, (l, t, r, b) = best
if dev > _INNER_MAX_DEVIATION:
return box, False
if outer_dev - dev < _INNER_MIN_IMPROVEMENT:
return box, False
if not (l or t or r or b):
return box, False
return (box[0] + l, box[1] + t, box[2] - r, box[3] - b), True
except Exception:
return box, False
def detect_card_box(img):
"""(box, refined?) — the card's own outline where that can be found.
`refined` is True when the box had to be pulled in from a surrounding
holder. Callers that measure the card's SHAPE need to know: the
refinement picks the rectangle closest to a standard card ratio, so
asking it afterwards whether the card has a standard ratio is circular
and would always answer yes. See aspect_profile.
"""
box = _detect_card_box(img)
if not box:
return None, False
return _refine_to_inner_card(img, box)
def _box_margin_reason(box, img_size):
"""None if the detected box leaves a plausible margin on enough sides
to trust; otherwise the reason it doesn't.
Exists because of a real failure: a card photographed still inside a
black display case, where the case filled the whole frame with no
visible background anywhere. _detect_card_box's background-from-corners
approach has nothing to key off in that situation, and it isn't obvious
from the box alone it returned (0, 23, 985, 1327) on a 986x1346
photo, which LOOKS like a plausible tight crop, not an obvious failure
like the too-small-area case already guarded against above. But at
essentially zero margin, every downstream measurement's outer/inner
sampling bands a few percent of the card's own short dimension, by
design, since real wear lives in the outermost sliver land entirely
inside whatever surrounds the true card, never reaching it. On that
photo they read the case's own embossed texture as "whitening": no
single edge disagreed with the others (the case is uniformly dark on
all four sides), so the per-edge consistency checks elsewhere in this
file never saw a reason to object.
Two real situations produce a near-zero margin, and there's no reliable
way to tell them apart from pixels alone: a card genuinely photographed
edge-to-edge with nothing but card in frame, or something surrounding
the card being read as part of it. Refusing both is the safer
direction a full-bleed card that loses its pixel measurement still
gets graded from the model's own eye, same as any other refusal here;
a case silently read as the card produces a confident, wrong number
that drags the whole grade down with it.
"""
w, h = img_size
left, top, right, bottom = box
margins = (left / w, top / h, (w - right) / w, (h - bottom) / h)
# Under 2.5% of that dimension, on at least two of the four sides.
if sum(1 for m in margins if m < 0.025) >= 2:
return ("this photo's card fills nearly the entire frame with no "
"usable margin around it, so the measurement bands — a few "
"percent of the card's own edge, where real wear actually "
"lives — would land on whatever's in that margin rather "
"than the card. Either the card was shot genuinely "
"edge-to-edge, or something around it (a case, a slab, a "
"holder) filled the frame instead. Reshoot with visible "
"background/mat around the card on all sides for a "
"measurement that means anything.")
return None
def framing_warning(image_bytes):
"""The margin problem described in _box_margin_reason, or None.
Public because the CROPS have the same exposure to it as the pixel
measurements do, and worse consequences. When box detection can't find
the card's real boundary, every crop is cut relative to the wrong
rectangle a "TOP-LEFT CORNER" close-up of a cased card shows the
CASE's corner bracket, with the card's actual corner off to one side.
The measurements at least refuse and say why; the crops keep being
produced and keep being captioned authoritatively, and the grading
prompt tells the model to judge corners/edges/surface *from* them. So
the caller needs to be able to warn about the framing rather than
silently pass off plastic as cardstock.
"""
if Image is None:
return None
try:
img = Image.open(io.BytesIO(image_bytes))
img.load()
box, _refined = detect_card_box(img)
box = box or (0, 0, img.width, img.height)
return _box_margin_reason(box, img.size)
except Exception:
return None
def _surface_map(piece):
"""A band-pass view that isolates surface texture from the artwork.
@ -430,7 +637,30 @@ def edge_wear_profile(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)
box, refined = detect_card_box(img)
box = box or (0, 0, img.width, img.height)
margin_reason = _box_margin_reason(box, img.size)
# A holder defeats this particular measurement even once the card
# itself has been located. Whitening is detected as the outermost
# band reading lighter and less saturated than the band just inside
# it — and a toploader's inner edge sits exactly there, adding its
# own reflection to the outer band on a perfectly clean card.
# Measured on a Fleer Ultra Jordan in a toploader: 58% and 53%
# "whitening" on two edges the model, reading the same strips by
# eye, called clean.
# Centering deliberately does NOT refuse here — it compares border
# WIDTHS, which is geometry that clear plastic doesn't distort, so
# it stays measurable through a holder.
if refined and not margin_reason:
margin_reason = (
"the card is inside a holder, sleeve or slab. Its cut edges "
"were located, but a whitening measurement reads the very "
"outermost sliver of the card — where the holder's own inner "
"edge and its reflections sit — so the number would describe "
"the plastic as much as the card. Judge the edges from the "
"strip images instead: cut lines and whitening are perfectly "
"visible through clear plastic even though they can't be "
"measured through it")
card = img.crop(box).convert("RGB")
short = min(card.size)
@ -479,22 +709,75 @@ def edge_wear_profile(image_bytes):
ss = sorted(c[1] for c in cols)
edge_medians[name] = (ls[len(ls) // 2], ss[len(ss) // 2])
# Two different failure shapes live here, and they need different
# responses. Both start from the same question — do all four edges'
# OWN median readings (one number per side, regardless of how many
# columns that side happened to contribute — see below for why that
# matters) describe one consistent border material?
#
# Deliberately NOT using the quantity-weighted pooled percentiles
# for this check, only the four per-edge medians. A portrait card's
# left/right edges are the long dimension and contribute far more
# columns than top/bottom — found on a real card photographed still
# inside a black display case, where left/right (case material,
# ~1135 columns each) outnumbered top/bottom (~90 columns each) by
# 12-to-1. Pooled by column, the wrong material dominates both the
# 25th and 75th percentile, so the refractor/iqr check below stays
# quiet even though two sides are reading something else entirely.
# Per-edge medians weight all four sides equally regardless of
# their length, which is what actually catches it.
outliers = {}
for name, (l_med, s_med) in edge_medians.items():
others = [v for n, v in edge_medians.items() if n != name]
if len(others) < 2:
continue
other_l = sorted(v[0] for v in others)[len(others) // 2]
other_s = sorted(v[1] for v in others)[len(others) // 2]
if (l_med - other_l) >= 45 and (other_s - s_med) >= 35:
outliers[name] = (
"this edge's finish reads as a different material from "
"the card's other edges (much brighter and less "
"saturated) — likely a clear acetate window, a die-cut "
"insert, or a foil accent on this side only, not "
"whitening. A paper-showing-through measurement doesn't "
"apply to a material that was never opaque to begin with."
whole_card_reason = None
if len(edge_medians) == 4:
names = list(edge_medians)
lumas_by_edge = {n: edge_medians[n][0] for n in names}
overall_spread = max(lumas_by_edge.values()) - min(lumas_by_edge.values())
# 60 is comfortably above ordinary lighting/exposure variation
# across a card's four sides (seen in practice: 10-30) and well
# below a genuine different-material gap (150+ for a die-cut
# window or a case's plastic against real cardstock). No
# saturation requirement here, unlike the old version of this
# check: a black case against a white or cream border is a huge
# luma gap with barely any saturation signal at all, since
# neither material has real colour to lose.
if overall_spread >= 60:
# Which single side, if any, explains the whole gap? Try
# excluding each one in turn and keep whichever exclusion
# leaves the tightest remaining trio.
best_excl, best_spread = None, None
for excl in names:
rest = [lumas_by_edge[n] for n in names if n != excl]
spread = max(rest) - min(rest)
if best_spread is None or spread < best_spread:
best_excl, best_spread = excl, spread
if best_spread < 45:
# A single outlier explains it — die-cut window, foil
# accent strip, one side only. Exclude just that side.
outliers[best_excl] = (
"this edge's finish reads as a different material "
"from the card's other edges — likely a clear "
"acetate window, a die-cut insert, or a foil accent "
"on this side only, not whitening. A "
"paper-showing-through measurement doesn't apply to "
"a material that was never opaque to begin with."
)
else:
# No single exclusion brings the rest into agreement —
# the readings split into two genuinely different
# groups (e.g. two sides read one material, two read
# another), so there's no way to tell algorithmically
# which pair is the card's real border. Refuse outright
# rather than guess.
whole_card_reason = (
"the four edges don't read as one consistent border "
"material — some sides measure roughly {:.0f} luma, "
"others roughly {:.0f}, with nothing in between. "
"This usually means the card is still in a case, "
"slab, or protective holder in the photo, so what "
"got measured on some sides is the holder, not the "
"card. Take the card out of anything it's in and "
"reshoot for a measurement that means anything."
).format(min(lumas_by_edge.values()), max(lumas_by_edge.values()))
# Baseline from the non-outlier edges pooled, not each edge against
# itself. Whitening only ever raises luma and lowers saturation, so
@ -530,17 +813,20 @@ def edge_wear_profile(image_bytes):
# 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 = margin_reason or whole_card_reason
if reason is None and 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:
elif reason is None and 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")
# A whole-card refusal makes every individual edge score meaningless
# too — there's no reliable baseline left to score any of them
# against, not just the sides that triggered it.
edges = {
name: (None if name in outliers
name: (None if (reason is not None or name in outliers)
else (_score_edge(cols, base_l, base_s) if cols else None))
for name, cols in collected.items()
}
@ -635,12 +921,17 @@ def centering_profile(image_bytes):
try:
img = Image.open(io.BytesIO(image_bytes))
img.load()
card = img.crop(_detect_card_box(img) or (0, 0, img.width, img.height))
box, _refined = detect_card_box(img)
box = box or (0, 0, img.width, img.height)
margin_reason = _box_margin_reason(box, img.size)
card = img.crop(box)
card = card.convert("RGB")
w, h = card.size
if w < 60 or h < 60:
return None
px = card.load()
if margin_reason:
return {"reliable": False, "reason": margin_reason}
# The border colour, sampled just inside the cut at the midpoint of
# each side — far from corners and from any design element. A small
@ -667,24 +958,56 @@ def centering_profile(image_bytes):
}
# All four sides must plausibly be the SAME border before a ratio of
# their widths means anything. A side whose colour sits far from the
# median of the other three is a different material — a clear die-cut
# window showing the background, a foil accent strip — and one such
# side invalidates the whole geometric premise, not just its own
# number: the walk inward on every side keys off one shared
# border_rgb. Refuse with the reason rather than return a ratio.
for side, colour in side_samples.items():
others = [v for s, v in side_samples.items() if s != side]
med = tuple(sorted(o[i] for o in others)[len(others) // 2]
# their widths means anything. Checking each side against the median
# of the other three, one at a time, and stopping at the first hit
# was tried first — and silently mishandled the case that matters
# most: a card still in a black case, where left/right sample the
# case (dark) and top/bottom sample the real card border (bright).
# That's a 2-vs-2 split, not one outlier — comparing "left" against
# the median of {right, top, bottom} lands on a bright value (2 of
# those 3 are bright), so left alone trips the check, the loop
# returns immediately, and "right" — equally case, equally wrong —
# is never even examined, so the message blames one side for a
# problem that's actually on two.
#
# This tries excluding each single side in turn and keeps whichever
# exclusion leaves the other three closest together, which correctly
# tells a real single-outlier case (die-cut window, foil strip —
# excluding it brings the rest into tight agreement) apart from a
# split where no single exclusion works, because two sides are wrong.
names = list(side_samples)
best_excl, best_spread = None, None
for excl in names:
rest = [side_samples[n] for n in names if n != excl]
spread = max(
max(v[i] for v in rest) - min(v[i] for v in rest) for i in range(3))
if best_spread is None or spread < best_spread:
best_excl, best_spread = excl, spread
overall_spread = max(
max(v[i] for v in side_samples.values()) - min(v[i] for v in side_samples.values())
for i in range(3))
if sum(abs(colour[i] - med[i]) for i in range(3)) > 150:
if overall_spread > 90:
if best_spread is not None and best_spread <= 40:
return {
"reliable": False,
"reason": ("the {} side's border reads as a completely "
"different colour/material from the other "
"sides — typical of a die-cut or clear-window "
"card, where a border-width ratio doesn't "
"describe centering at all").format(side),
"describe centering at all").format(best_excl),
}
return {
"reliable": False,
"reason": ("the four sides don't read as one consistent "
"border — they split into at least two different "
"colours/materials with no single side "
"explaining it. This usually means the card is "
"still in a case, slab, or protective holder in "
"the photo, so some sides measured the holder "
"instead of the card. Take it out and reshoot "
"for a centering measurement that means "
"anything."),
}
samples = list(side_samples.values())
@ -769,12 +1092,41 @@ def aspect_profile(image_bytes):
try:
img = Image.open(io.BytesIO(image_bytes))
img.load()
# Deliberately the RAW box, not the refined one every other caller
# uses. _refine_to_inner_card picks whichever candidate rectangle
# best matches a standard card ratio — so measuring that rectangle's
# ratio and asking "is this a standard card ratio?" is circular, and
# would answer yes on a genuinely trimmed card. Trimming detection
# only means anything against a boundary found without reference to
# the answer.
box = _detect_card_box(img)
if not box:
return None
bw, bh = box[2] - box[0], box[3] - box[1]
if bw < 40 or bh < 40:
return None
# This measurement IS the detected box, so a box that didn't find
# the card's real boundary doesn't produce an approximate ratio —
# it produces the ratio of something else entirely. On a cased card
# it reported 5.8% off standard, which is the CASE's proportions;
# the UI would then show a trimming hint about a card it never
# measured. Same guard as edge/centering, and more directly
# load-bearing here than for either of those.
margin_reason = _box_margin_reason(box, img.size)
if margin_reason:
return {"reliable": False, "reason": margin_reason}
# A card sitting inside a holder is the other way this box can be
# the wrong rectangle — the margin check won't catch it when the
# holder itself is well framed. Refuse rather than report the
# holder's proportions as the card's.
if _refine_to_inner_card(img, box)[1]:
return {
"reliable": False,
"reason": ("the card appears to be inside a holder, sleeve or "
"slab, so the outline measured here is the "
"holder's rather than the card's. Its proportions "
"say nothing about whether the card was trimmed"),
}
ratio = min(bw, bh) / float(max(bw, bh))
against_standards = {
@ -787,6 +1139,7 @@ def aspect_profile(image_bytes):
best_name = min(against_standards, key=lambda n: against_standards[n]["deviation_percent"])
return {
"reliable": True,
"measured_ratio": round(ratio, 4),
"width_px": bw,
"height_px": bh,
@ -896,7 +1249,8 @@ def detail_crops(image_bytes, filename="card.jpg", corners=True, edges=True,
if max(img.size) < MIN_SOURCE_PX:
return []
box = _detect_card_box(img) or (0, 0, img.width, img.height)
box, _refined = detect_card_box(img)
box = box or (0, 0, img.width, img.height)
card = img.crop(box)
w, h = card.width, card.height
stem = filename.rsplit(".", 1)[0]

View file

@ -157,7 +157,8 @@ function renderGradeBlock(g, opts = {}) {
// would just be clutter, not information. Shown against its best-matching
// standard; which standard is actually relevant is a judgment call the
// model made with the photo in hand, not something this line re-derives.
const am = g.aspect_measurement || null;
const am = (g.aspect_measurement && g.aspect_measurement.reliable !== false)
? g.aspect_measurement : null;
const bestDev = am && am.against_standards && am.best_match
? am.against_standards[am.best_match].deviation_percent : null;
const aspectLine = (bestDev !== null && bestDev >= 3)
@ -182,7 +183,16 @@ function renderGradeBlock(g, opts = {}) {
</tr>`;
}).join('');
const auth = g.authenticity || null;
const authBanner = (auth && auth.flag !== 'none') ? `
<div class="authenticity-banner authenticity-${auth.flag === 'likely_not_genuine' ? 'high' : 'check'}">
<div class="authenticity-title">${auth.flag === 'likely_not_genuine'
? 'Possibly not a genuine card' : 'Worth double-checking'}</div>
${auth.observation ? `<div class="authenticity-detail">${esc(auth.observation)}</div>` : ''}
</div>` : '';
return `
${authBanner}
<div class="note note-warn">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>
@ -321,9 +331,22 @@ function resetGradeState() {
renderGradeReview();
}
// Matches MAX_GRADE_IMAGES in app.py — the server rejects more than this,
// so the UI has to stop at the same number rather than let someone pick a
// seventh photo and only find out when grading fails.
const MAX_PHOTOS = 6;
async function addPickedFrom(input) {
const room = MAX_PHOTOS - gradeState.files.length;
if (room <= 0) {
// Was a silent no-op: readPickedImages would slice to nothing and
// return an empty array, so the tap did nothing with no explanation.
banner(`That's the ${MAX_PHOTOS}-photo limit for one card — remove one, or grade these.`, true);
input.value = '';
return;
}
try {
const picked = await readPickedImages(input, 6 - gradeState.files.length);
const picked = await readPickedImages(input, room);
if (!picked.length) return;
gradeState.files.push(...picked);
renderGradeReview();
@ -339,10 +362,17 @@ $('#btn-grade').addEventListener('click', () => {
});
$('#grade-file').addEventListener('change', (e) => addPickedFrom(e.target));
// Camera shots add onto whatever's already picked (front, then flip to the
// back for a second shot) rather than resetting — resetGradeState() is only
// for starting a fresh card, which the library button already does.
$('#btn-camera').addEventListener('click', () => $('#grade-camera').click());
// Camera shots add onto whatever's already PICKED (front, then flip to the
// back for a second shot) rather than resetting. But once a result is on
// screen that card is finished, and adding to it is meaningless: the result
// view has no photo strip, so renderGradeReview would keep showing the old
// verdict while the new photos piled up invisibly behind it — silently, and
// all the way to the 6-photo cap. Starting a new card is the only sensible
// reading of "take a photo" at that point.
$('#btn-camera').addEventListener('click', () => {
if (gradeState.result) resetGradeState();
$('#grade-camera').click();
});
$('#grade-camera').addEventListener('change', (e) => addPickedFrom(e.target));
$('#grade-review').addEventListener('click', (e) => {
if (e.target.closest('#grade-add-more')) { $('#grade-file').click(); return; }
@ -398,14 +428,23 @@ 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) => `
$('#history-body').innerHTML = rows.map((g) => {
const auth = g.authenticity;
// A real badge, not just a dot — this is the thing that has to catch
// the eye scanning the home-screen list, not something you only notice
// once you already suspect a card and go looking for it.
const authBadge = (auth && auth.flag !== 'none')
? `<span class="pill authenticity-pill authenticity-${auth.flag === 'likely_not_genuine' ? 'high' : 'check'}">${
esc(auth.flag === 'likely_not_genuine' ? 'Possibly fake' : 'Verify')}</span>`
: '';
return `
<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 class="cardcell-meta">${g.card_type ? `${esc(TYPE_LABEL[g.card_type] || 'Card')} · ` : ''}${g.image_count || 1} photo(s)${authBadge ? ' ' + authBadge : ''}</div>
</div>
</div>
</td>
@ -420,7 +459,8 @@ function renderHistory() {
<button class="btn btn-quiet btn-sm" data-history-delete="${g.id}">Delete</button>
</div>
</td>
</tr>`).join('');
</tr>`;
}).join('');
}
/* ----------------------------------------------------------------- spend */

View file

@ -422,6 +422,53 @@ textarea { resize: vertical; min-height: 64px; }
margin-top: 0; margin-bottom: 14px;
}
/* --------------------------------------------------------- authenticity */
/* A concern about whether the card is what it claims to be, not its
condition deliberately styled as a full banner rather than a hint line
like every other measurement caveat, so it reads as "stop and look at
this" before anything about the grade itself. Two tiers, both using
--critical rather than --brand: this is exactly the "distrust this"
signal that colour is reserved for elsewhere in the app, and the brand
red would blur into decoration here of all places. */
.authenticity-banner {
border: 1px solid color-mix(in srgb, var(--critical) 45%, transparent);
background: color-mix(in srgb, var(--critical) 10%, var(--surface-1));
border-radius: 8px;
padding: 11px 14px;
margin-bottom: 14px;
}
.authenticity-check {
border-color: color-mix(in srgb, var(--warning) 55%, transparent);
background: color-mix(in srgb, var(--warning) 12%, var(--surface-1));
}
.authenticity-title {
font-weight: 700; font-size: 13.5px;
color: var(--critical);
}
.authenticity-check .authenticity-title { color: var(--warning); }
.authenticity-detail {
font-size: 12.5px; color: var(--ink-2); margin-top: 3px; line-height: 1.5;
}
/* History-row badge: a real pill, not just a dot needs to catch the eye
scanning the home-screen list itself, not only reward someone who
already suspects a card and opens it to check. Same colour-mix technique
as .pill-critical/.pill-marginal elsewhere, just spelled out explicitly
since neither of those set the text colour this needs. */
.authenticity-pill {
margin-left: 2px;
}
.authenticity-pill.authenticity-high {
border-color: color-mix(in srgb, var(--critical) 55%, transparent);
color: var(--critical);
background: color-mix(in srgb, var(--critical) 15%, transparent);
}
.authenticity-pill.authenticity-check {
border-color: color-mix(in srgb, var(--warning) 55%, transparent);
color: var(--warning);
background: color-mix(in srgb, var(--warning) 15%, transparent);
}
/* -------------------------------------------------------------- banner */
.banner {

View file

@ -6,6 +6,7 @@ it told you, so you can look back at a card without re-running the estimate.
"""
import base64
import binascii
import json
import os
import sqlite3
@ -37,6 +38,7 @@ CREATE TABLE IF NOT EXISTS grades (
grade_high INTEGER,
confidence TEXT,
categories_json TEXT,
authenticity_json TEXT,
edge_measurements_json TEXT,
centering_measurement_json TEXT,
aspect_measurement_json TEXT,
@ -126,6 +128,7 @@ def init():
for statement in (
"ALTER TABLE grades ADD COLUMN aspect_measurement_json TEXT",
"ALTER TABLE grades ADD COLUMN source_images_json TEXT",
"ALTER TABLE grades ADD COLUMN authenticity_json TEXT",
):
try:
conn.execute(statement)
@ -200,8 +203,24 @@ def _decode_images(raw):
items = json.loads(raw)
except (ValueError, TypeError):
return None
return [(base64.standard_b64decode(item["image_base64"]), item.get("filename") or "upload")
# Decoding is inside the guard too, not just the JSON parse: a row
# written by an older/newer shape, or truncated, would otherwise raise
# KeyError/binascii.Error out of a plain read and 500 the request.
# Falling back to "no stored photos" degrades to the re-pick path,
# which already exists and is the right outcome here.
try:
decoded = [(base64.standard_b64decode(item["image_base64"]),
item.get("filename") or "upload")
for item in items]
except (KeyError, TypeError, ValueError, binascii.Error):
return None
# b64decode doesn't raise on some malformed input, it just returns b''
# — so a corrupt row would otherwise hand grading a zero-byte "photo"
# and fail confusingly deep in the pipeline. Treat any empty entry as
# the row being unusable and fall back to asking for the photo again.
if not decoded or any(not b for b, _ in decoded):
return None
return decoded
def save_grade(grade, thumbnail=None, label=None, source_images=None):
@ -219,11 +238,12 @@ def save_grade(grade, thumbnail=None, label=None, source_images=None):
"(created_at, label, card_type, card_note, image_count, thumbnail, "
" model, estimated_grade, "
" grade_low, grade_high, confidence, categories_json, "
" authenticity_json, "
" edge_measurements_json, centering_measurement_json, "
" aspect_measurement_json, "
" limitations_json, note, estimated_cost, usage_json, "
" source_images_json) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(
now(), label, grade.get("card_type"), grade.get("card_note"),
grade.get("image_count"), thumbnail,
@ -231,6 +251,7 @@ def save_grade(grade, thumbnail=None, label=None, source_images=None):
grade.get("estimated_grade"), grade.get("grade_low"),
grade.get("grade_high"), grade.get("confidence"),
json.dumps(grade.get("categories")),
json.dumps(grade.get("authenticity")),
json.dumps(grade.get("edge_measurements")),
json.dumps(grade.get("centering_measurement")),
json.dumps(grade.get("aspect_measurement")),
@ -264,6 +285,7 @@ def update_grade_result(grade_id, grade, thumbnail=None, source_images=None):
grade.get("estimated_grade"), grade.get("grade_low"),
grade.get("grade_high"), grade.get("confidence"),
json.dumps(grade.get("categories")),
json.dumps(grade.get("authenticity")),
json.dumps(grade.get("edge_measurements")),
json.dumps(grade.get("centering_measurement")),
json.dumps(grade.get("aspect_measurement")),
@ -274,7 +296,7 @@ def update_grade_result(grade_id, grade, thumbnail=None, source_images=None):
sql = (
"UPDATE grades SET created_at=?, card_type=?, card_note=?, "
"image_count=?, model=?, estimated_grade=?, grade_low=?, "
"grade_high=?, confidence=?, categories_json=?, "
"grade_high=?, confidence=?, categories_json=?, authenticity_json=?, "
"edge_measurements_json=?, centering_measurement_json=?, "
"aspect_measurement_json=?, limitations_json=?, note=?, "
"estimated_cost=?, usage_json=?"
@ -404,7 +426,7 @@ def get_grade_images(grade_id):
def _row_to_grade(row):
d = dict(row)
for key in ("categories_json", "edge_measurements_json",
for key in ("categories_json", "authenticity_json", "edge_measurements_json",
"centering_measurement_json", "aspect_measurement_json",
"limitations_json", "usage_json"):
out_key = key[:-len("_json")]
@ -429,9 +451,10 @@ def _row_to_grade(row):
_LIST_COLUMNS = (
"id, 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, "
"aspect_measurement_json, limitations_json, note, estimated_cost, "
"usage_json, (source_images_json IS NOT NULL) AS source_images_json"
"categories_json, authenticity_json, edge_measurements_json, "
"centering_measurement_json, aspect_measurement_json, limitations_json, "
"note, estimated_cost, usage_json, "
"(source_images_json IS NOT NULL) AS source_images_json"
)

181
vision.py
View file

@ -85,12 +85,14 @@ MODELS = {
"adaptive_thinking": False,
"fallbacks": False,
"in_per_mtok": 2.00, "out_per_mtok": 10.00,
# A rough estimate, unlike the Anthropic figures (which were true'd
# up against real usage — see the README's grading-cost note). Only
# affects the ADVERTISED per-grade estimate in Settings; the actual
# billed cost always comes from the real usage this API call
# reports, never from this number.
"approx_image_tokens": 1500,
# Calibrated against 8 real single/two-photo grades once this model
# had actually been used (median 18.4k in / 1.2k out). The initial
# 1500 was a guess made before any real call existed and advertised
# roughly 2.2x under the true cost — a guess is fine to start from,
# but it has to be trued up once real usage exists, exactly as the
# Anthropic figures were.
"approx_image_tokens": 3560,
"approx_output_tokens": 1180,
},
}
@ -363,13 +365,21 @@ 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
55/45 ............. 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
PSA additionally allows a 5% LEEWAY on these front minimums for cards \
grading 7 or better so a 10's 55/45 can stretch toward 60/40, a 9's \
60/40 toward 65/35, and so on up to a 7. That leeway is the grader's \
discretion and keys off overall eye appeal, not an automatic second \
tolerance: a card just past the published line is genuinely borderline \
rather than either automatically passing or automatically failing, so \
give the range at that boundary rather than a confident single grade. No \
leeway applies at 6 or below.
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 \
@ -521,6 +531,51 @@ the untouched crop too.
On chrome and refractor stock, expect fine scratching; it is the norm rather \
than the exception, and its absence is what is notable.
AUTHENTICITY separate from all four categories above, and separate from \
condition entirely. A pristine card that isn't a genuine product doesn't get \
a PSA grade at all; PSA authenticates before it grades, and a card that fails \
that step never reaches the 1-10 scale regardless of how clean it looks. \
Judge this from what's actually visible, the same discipline as everything \
else here flag it when you see a real, specific tell, not as a hedge.
Signs the card may not be an official print at all: a set, era, or \
name/number combination that doesn't correspond to any real printing you \
recognize; card text in a font, layout, or language inconsistent with the \
publisher and era it's claiming to be; artwork that reads as fan-made or \
AI-generated rather than the actual official illustration for that card; \
visible printing quality far below what mass-produced card stock looks like \
soft/pixelated detail, visible inkjet or laser-printer banding, a paper \
stock that looks like cardstock from a home printer rather than the glossy, \
consistent stock real trading cards use; or the word "proxy," "custom," \
"fan art," or similar printed anywhere on the card itself.
Signs a real card might be a counterfeit of a genuine printing: colour \
saturation, font weight, or line quality that doesn't match known genuine \
copies of that exact card; a holo or foil pattern that looks wrong for the \
set (wrong pattern entirely, or applied where the real printing doesn't have \
one); a cardback design, layout, or copyright text that doesn't match the \
genuine card. Weigh these against your own uncertainty you're comparing \
against a memory of what genuine copies look like, not a reference image, so \
reserve likely_not_genuine for cases where something is clearly, specifically \
wrong, not just "this looks a little different than I'd expect."
TRIMMING is the physical-alteration version of this same concern, already \
covered under CORNERS above a genuine card that's been cut down to fake \
sharper edges than it actually has. When you flag trimming there, also set \
this field (worth_checking is usually right, since a photo can't confirm it \
the way calipers can) so it surfaces in one place rather than only inside a \
corners observation.
flag "none" is the ordinary case and should be the overwhelming majority \
of cards most cards people photograph for grading are exactly what they \
appear to be, and reaching for suspicion without a specific tell is its own \
kind of dishonesty, the same failure as inventing wear that isn't there. Use \
"worth_checking" when you see something specific but can't be confident from \
a photo alone say what you saw. Use "likely_not_genuine" only when you're \
reasonably confident, not just uneasy a fictional set name, visible \
"proxy" text, or artwork that plainly isn't the real official print are the \
kind of thing that earns this level; a card that's merely unfamiliar to you \
does not.
If you flag likely_not_genuine, say so in note and reflect it in the grade: \
estimated_grade should be null (this isn't a card PSA would assign a number \
to at all), with the reasoning in note rather than in a numeric range.
HOW PSA COMBINES THE FOUR
The grade is capped by the WORST attribute, not averaged across them. Three \
@ -647,6 +702,15 @@ centering, that range should be genuinely wide (e.g. 6-9), not cosmetic.
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.
Centering and edge whitening are measured directly from the pixels when a \
MEASURED CENTERING / MEASURED EDGE WHITENING block is supplied above that's \
a materially stronger basis than reading either by eye, and the reverse is \
true too: if NEITHER was measurable on this card (both missing or refused, \
with a reason given), don't call this "high" even if the photo itself is \
sharp and well-lit. A crisp photo of a card whose finish defeats the \
measurement (foil, refractor, die-cut, or the margin problem described \
above) still leaves you assessing two of the four categories by eye alone, \
which is exactly what "medium" is for.
- 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").
@ -670,6 +734,25 @@ GRADE_CATEGORY_SCHEMA = {
"additionalProperties": False,
}
AUTHENTICITY_SCHEMA = {
"type": "object",
"properties": {
"flag": {
"type": "string",
"enum": ["none", "worth_checking", "likely_not_genuine"],
"description": "Whether anything about this card's legitimacy, "
"not its condition, is in question.",
},
"observation": {
"type": "string",
"description": "The specific thing that prompted the flag — "
"empty string when flag is 'none'.",
},
},
"required": ["flag", "observation"],
"additionalProperties": False,
}
GRADING_SCHEMA = {
"type": "object",
"properties": {
@ -682,6 +765,7 @@ GRADING_SCHEMA = {
"type": "string",
"description": "One line naming the card if legible (player/name, set, year) — for the history list.",
},
"authenticity": AUTHENTICITY_SCHEMA,
"estimated_grade": {
"type": ["integer", "null"],
"description": "Best single estimate, whole number 1-10, or null if ungradeable from these photos.",
@ -699,9 +783,9 @@ GRADING_SCHEMA = {
},
"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"],
"required": ["card_type", "card_note", "authenticity", "estimated_grade",
"grade_low", "grade_high", "confidence", "centering", "corners",
"edges", "surface", "limitations", "note"],
"additionalProperties": False,
}
@ -720,6 +804,14 @@ def _clean_category(raw):
return {"severity": severity, "observation": raw.get("observation") or ""}
def _clean_authenticity(raw):
raw = raw if isinstance(raw, dict) else {}
flag = raw.get("flag")
if flag not in ("none", "worth_checking", "likely_not_genuine"):
flag = "none"
return {"flag": flag, "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).
@ -771,6 +863,9 @@ def grade_card(images, api_key=None, model=None, effort=None, zoom_details=True)
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
aspect = cardimage.aspect_profile(images[0][0]) if can_measure else None
# Same detection failure that makes the measurements refuse also
# mis-frames every crop — see cardimage.framing_warning.
framing = cardimage.framing_warning(images[0][0]) if (crops or can_measure) else None
prompt_parts = []
if centering and centering.get("reliable") is False:
@ -864,7 +959,7 @@ def grade_card(images, api_key=None, model=None, effort=None, zoom_details=True)
"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 aspect:
if aspect and aspect.get("reliable") is not False:
standard_rows = "\n".join(
" {}: {:.4f} standard → {:.1f}% deviation".format(
name, data["standard_ratio"], data["deviation_percent"])
@ -904,6 +999,34 @@ def grade_card(images, api_key=None, model=None, effort=None, zoom_details=True)
"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.")
if framing:
prompt_parts.append(
"CAVEAT ON THOSE CROPS: {} The crop rectangle comes from that "
"same detection, so each close-up is cut against the wrong "
"boundary — most often because the card is in a toploader, "
"sleeve or case and the detection found the HOLDER's outline "
"instead of the card's. The card's own corner or edge is "
"therefore sitting inside the frame rather than at the crop's "
"own corner. Locate it and judge that.\n"
"Being in a holder is NOT by itself a reason to give up on "
"corners or edges. A toploader or penny sleeve is clear "
"plastic: corner sharpness, corner whitening, edge chipping "
"and edge whitening all read straight through it, and the "
"card's cut line is normally plainly visible as a distinct "
"boundary inside the holder's. Judge those categories "
"normally — just make sure you are reading the CARD's "
"boundary and not the holder's, and never count scuffing, "
"scratching or whitening that belongs to the plastic as "
"damage to the card. Reserve cannot_assess for a corner or "
"edge genuinely hidden — obscured by a label, blown out by "
"glare, or out of frame — not merely for being behind clear "
"plastic.\n"
"SURFACE is the real exception. Scratches, dust, haze and "
"glare on a holder sit directly over the card's own surface "
"and cannot be separated from it in a photo, so "
"cannot_assess IS the honest answer for surface on a card "
"shot through a scratched or dusty holder — say so plainly "
"and name the holder as the reason.".format(framing))
prompt_parts.append("Estimate the PSA grade this trading card would likely receive.")
parsed, usage = _call_vision(
@ -920,24 +1043,51 @@ def grade_card(images, api_key=None, model=None, effort=None, zoom_details=True)
if card_type not in ("pokemon", "sports", "other_tcg", "other"):
card_type = "other"
confidence = parsed.get("confidence") or "low"
limitations = [l for l in (parsed.get("limitations") or []) if l]
# "high" confidence is defined to the model as being able to confidently
# assess all four categories — but centering and edge whitening are the
# two this app can measure from pixels rather than ask the model to
# judge by eye, and a self-reported "high" doesn't reliably account for
# whether that measurement was actually available on THIS card. Capped
# here rather than left to the prompt alone: the model juggles a long
# instruction list already, and this is exactly the kind of objective,
# checkable fact code should enforce rather than hope gets weighed
# correctly every time. Only caps a "high" claim down to "medium" when
# NEITHER was measured — one of two still measured is a real basis the
# model may legitimately be confident from, so that's left to its own
# judgment.
def _measured_ok(m):
return bool(m) and m.get("reliable", True) is not False
if confidence == "high" and not _measured_ok(centering) and not _measured_ok(measured):
confidence = "medium"
limitations.append(
"Confidence capped at medium: neither centering nor edge "
"whitening could be measured from the pixels on this photo, so "
"the estimate leans on the photo alone for two of PSA's four "
"categories rather than a direct pixel measurement for either.")
return {
"closeups": len(crops),
"card_type": card_type,
"card_note": (parsed.get("card_note") or "").strip(),
"authenticity": _clean_authenticity(parsed.get("authenticity")),
"edge_measurements": measured,
"centering_measurement": centering,
"aspect_measurement": aspect,
"estimated_grade": _clean_grade(parsed.get("estimated_grade")),
"grade_low": low,
"grade_high": high,
"confidence": parsed.get("confidence") or "low",
"confidence": confidence,
"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],
"limitations": limitations,
"note": parsed.get("note") or "",
"usage": usage,
}
@ -975,8 +1125,11 @@ def price_guide():
guide = {}
for model_id, caps in MODELS.items():
# 1 supplied photo + ~12 generated close-ups, JSON verdict out.
# Per-model output estimate where one is known: GPT-5.6 Sol
# reliably writes ~1.2k tokens of verdict against Sonnet's ~0.9k,
# enough to matter at these prices.
est_in = caps["approx_image_tokens"] * 5 + 600
est_out = 700
est_out = caps.get("approx_output_tokens", 700)
rate_in, rate_out = current_rates(caps)
guide[model_id] = {
"label": caps["label"],