Compare commits

..

No commits in common. "bc6ee2cf279a4714381bab4f22f53b8497328aab" and "034761e145fd514c56c23feddcb65064bb32257f" have entirely different histories.

7 changed files with 65 additions and 828 deletions

139
CLAUDE.md
View file

@ -1,139 +0,0 @@
# 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,12 +432,6 @@ class Handler(BaseHTTPRequestHandler):
# spending yours. Never stored — it lives in their browser and is # spending yours. Never stored — it lives in their browser and is
# used for this one call. # used for this one call.
caller_key = (body.get("api_key") or "").strip() or None 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( print("[grade] calling vision model={} on {} image(s){}".format(
model, len(images), " (caller key)" if caller_key else ""), flush=True) model, len(images), " (caller key)" if caller_key else ""), flush=True)
@ -459,7 +453,6 @@ class Handler(BaseHTTPRequestHandler):
"closeups": result["closeups"], "closeups": result["closeups"],
"card_type": result["card_type"], "card_type": result["card_type"],
"card_note": result["card_note"], "card_note": result["card_note"],
"authenticity": result["authenticity"],
"edge_measurements": result["edge_measurements"], "edge_measurements": result["edge_measurements"],
"centering_measurement": result["centering_measurement"], "centering_measurement": result["centering_measurement"],
"aspect_measurement": result["aspect_measurement"], "aspect_measurement": result["aspect_measurement"],

View file

@ -241,213 +241,6 @@ def _detect_card_box(img):
return box 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): def _surface_map(piece):
"""A band-pass view that isolates surface texture from the artwork. """A band-pass view that isolates surface texture from the artwork.
@ -637,30 +430,7 @@ def edge_wear_profile(image_bytes):
img.load() img.load()
if img.mode not in ("RGB", "L"): if img.mode not in ("RGB", "L"):
img = img.convert("RGB") img = img.convert("RGB")
box, refined = detect_card_box(img) box = _detect_card_box(img) or (0, 0, img.width, img.height)
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") card = img.crop(box).convert("RGB")
short = min(card.size) short = min(card.size)
@ -709,75 +479,22 @@ def edge_wear_profile(image_bytes):
ss = sorted(c[1] for c in cols) ss = sorted(c[1] for c in cols)
edge_medians[name] = (ls[len(ls) // 2], ss[len(ss) // 2]) 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 = {} outliers = {}
whole_card_reason = None for name, (l_med, s_med) in edge_medians.items():
if len(edge_medians) == 4: others = [v for n, v in edge_medians.items() if n != name]
names = list(edge_medians) if len(others) < 2:
lumas_by_edge = {n: edge_medians[n][0] for n in names} continue
overall_spread = max(lumas_by_edge.values()) - min(lumas_by_edge.values()) other_l = sorted(v[0] for v in others)[len(others) // 2]
# 60 is comfortably above ordinary lighting/exposure variation other_s = sorted(v[1] for v in others)[len(others) // 2]
# across a card's four sides (seen in practice: 10-30) and well if (l_med - other_l) >= 45 and (other_s - s_med) >= 35:
# below a genuine different-material gap (150+ for a die-cut outliers[name] = (
# window or a case's plastic against real cardstock). No "this edge's finish reads as a different material from "
# saturation requirement here, unlike the old version of this "the card's other edges (much brighter and less "
# check: a black case against a white or cream border is a huge "saturated) — likely a clear acetate window, a die-cut "
# luma gap with barely any saturation signal at all, since "insert, or a foil accent on this side only, not "
# neither material has real colour to lose. "whitening. A paper-showing-through measurement doesn't "
if overall_spread >= 60: "apply to a material that was never opaque to begin with."
# 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 # Baseline from the non-outlier edges pooled, not each edge against
# itself. Whitening only ever raises luma and lowers saturation, so # itself. Whitening only ever raises luma and lowers saturation, so
@ -813,20 +530,17 @@ def edge_wear_profile(image_bytes):
# answer is the right outcome there; a wrong number is worse than no # answer is the right outcome there; a wrong number is worse than no
# number, because it drags the whole grade down with it. # number, because it drags the whole grade down with it.
iqr = lumas[int(len(lumas) * 0.75)] - lumas[int(len(lumas) * 0.25)] iqr = lumas[int(len(lumas) * 0.75)] - lumas[int(len(lumas) * 0.25)]
reason = margin_reason or whole_card_reason reason = None
if reason is None and base_l >= 205 and base_s <= 45: if base_l >= 205 and base_s <= 45:
reason = ("the card's border is white, silver or foil, where paper " reason = ("the card's border is white, silver or foil, where paper "
"showing through looks the same as the border itself") "showing through looks the same as the border itself")
elif reason is None and iqr >= 70: elif iqr >= 70:
reason = ("the border's brightness varies too much across the card " reason = ("the border's brightness varies too much across the card "
"— typical of a refractor or prismatic finish — for a " "— typical of a refractor or prismatic finish — for a "
"whitening measurement to mean anything") "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 = { edges = {
name: (None if (reason is not None or name in outliers) name: (None if name in outliers
else (_score_edge(cols, base_l, base_s) if cols else None)) else (_score_edge(cols, base_l, base_s) if cols else None))
for name, cols in collected.items() for name, cols in collected.items()
} }
@ -921,17 +635,12 @@ def centering_profile(image_bytes):
try: try:
img = Image.open(io.BytesIO(image_bytes)) img = Image.open(io.BytesIO(image_bytes))
img.load() img.load()
box, _refined = detect_card_box(img) card = img.crop(_detect_card_box(img) or (0, 0, img.width, img.height))
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") card = card.convert("RGB")
w, h = card.size w, h = card.size
if w < 60 or h < 60: if w < 60 or h < 60:
return None return None
px = card.load() px = card.load()
if margin_reason:
return {"reliable": False, "reason": margin_reason}
# The border colour, sampled just inside the cut at the midpoint of # The border colour, sampled just inside the cut at the midpoint of
# each side — far from corners and from any design element. A small # each side — far from corners and from any design element. A small
@ -958,57 +667,25 @@ def centering_profile(image_bytes):
} }
# All four sides must plausibly be the SAME border before a ratio of # All four sides must plausibly be the SAME border before a ratio of
# their widths means anything. Checking each side against the median # their widths means anything. A side whose colour sits far from the
# of the other three, one at a time, and stopping at the first hit # median of the other three is a different material — a clear die-cut
# was tried first — and silently mishandled the case that matters # window showing the background, a foil accent strip — and one such
# most: a card still in a black case, where left/right sample the # side invalidates the whole geometric premise, not just its own
# case (dark) and top/bottom sample the real card border (bright). # number: the walk inward on every side keys off one shared
# That's a 2-vs-2 split, not one outlier — comparing "left" against # border_rgb. Refuse with the reason rather than return a ratio.
# the median of {right, top, bottom} lands on a bright value (2 of for side, colour in side_samples.items():
# those 3 are bright), so left alone trips the check, the loop others = [v for s, v in side_samples.items() if s != side]
# returns immediately, and "right" — equally case, equally wrong — med = tuple(sorted(o[i] for o in others)[len(others) // 2]
# is never even examined, so the message blames one side for a for i in range(3))
# problem that's actually on two. if sum(abs(colour[i] - med[i]) for i in range(3)) > 150:
#
# 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 overall_spread > 90:
if best_spread is not None and best_spread <= 40:
return { return {
"reliable": False, "reliable": False,
"reason": ("the {} side's border reads as a completely " "reason": ("the {} side's border reads as a completely "
"different colour/material from the other " "different colour/material from the other "
"sides — typical of a die-cut or clear-window " "sides — typical of a die-cut or clear-window "
"card, where a border-width ratio doesn't " "card, where a border-width ratio doesn't "
"describe centering at all").format(best_excl), "describe centering at all").format(side),
} }
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()) samples = list(side_samples.values())
border_rgb = tuple(sorted(c[i] for c in samples)[len(samples) // 2] border_rgb = tuple(sorted(c[i] for c in samples)[len(samples) // 2]
@ -1092,41 +769,12 @@ def aspect_profile(image_bytes):
try: try:
img = Image.open(io.BytesIO(image_bytes)) img = Image.open(io.BytesIO(image_bytes))
img.load() 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) box = _detect_card_box(img)
if not box: if not box:
return None return None
bw, bh = box[2] - box[0], box[3] - box[1] bw, bh = box[2] - box[0], box[3] - box[1]
if bw < 40 or bh < 40: if bw < 40 or bh < 40:
return None 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)) ratio = min(bw, bh) / float(max(bw, bh))
against_standards = { against_standards = {
@ -1139,7 +787,6 @@ def aspect_profile(image_bytes):
best_name = min(against_standards, key=lambda n: against_standards[n]["deviation_percent"]) best_name = min(against_standards, key=lambda n: against_standards[n]["deviation_percent"])
return { return {
"reliable": True,
"measured_ratio": round(ratio, 4), "measured_ratio": round(ratio, 4),
"width_px": bw, "width_px": bw,
"height_px": bh, "height_px": bh,
@ -1249,8 +896,7 @@ def detail_crops(image_bytes, filename="card.jpg", corners=True, edges=True,
if max(img.size) < MIN_SOURCE_PX: if max(img.size) < MIN_SOURCE_PX:
return [] return []
box, _refined = detect_card_box(img) box = _detect_card_box(img) or (0, 0, img.width, img.height)
box = box or (0, 0, img.width, img.height)
card = img.crop(box) card = img.crop(box)
w, h = card.width, card.height w, h = card.width, card.height
stem = filename.rsplit(".", 1)[0] stem = filename.rsplit(".", 1)[0]

View file

@ -157,8 +157,7 @@ function renderGradeBlock(g, opts = {}) {
// would just be clutter, not information. Shown against its best-matching // would just be clutter, not information. Shown against its best-matching
// standard; which standard is actually relevant is a judgment call the // standard; which standard is actually relevant is a judgment call the
// model made with the photo in hand, not something this line re-derives. // model made with the photo in hand, not something this line re-derives.
const am = (g.aspect_measurement && g.aspect_measurement.reliable !== false) const am = g.aspect_measurement || null;
? g.aspect_measurement : null;
const bestDev = am && am.against_standards && am.best_match const bestDev = am && am.against_standards && am.best_match
? am.against_standards[am.best_match].deviation_percent : null; ? am.against_standards[am.best_match].deviation_percent : null;
const aspectLine = (bestDev !== null && bestDev >= 3) const aspectLine = (bestDev !== null && bestDev >= 3)
@ -183,16 +182,7 @@ function renderGradeBlock(g, opts = {}) {
</tr>`; </tr>`;
}).join(''); }).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 ` return `
${authBanner}
<div class="note note-warn">A photo can't show surface scratches, print lines, or light edge wear the <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. 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> Treat this as a rough screen, not a prediction of what it comes back as.</div>
@ -331,22 +321,9 @@ function resetGradeState() {
renderGradeReview(); 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) { 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 { try {
const picked = await readPickedImages(input, room); const picked = await readPickedImages(input, 6 - gradeState.files.length);
if (!picked.length) return; if (!picked.length) return;
gradeState.files.push(...picked); gradeState.files.push(...picked);
renderGradeReview(); renderGradeReview();
@ -362,17 +339,10 @@ $('#btn-grade').addEventListener('click', () => {
}); });
$('#grade-file').addEventListener('change', (e) => addPickedFrom(e.target)); $('#grade-file').addEventListener('change', (e) => addPickedFrom(e.target));
// Camera shots add onto whatever's already PICKED (front, then flip to the // 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 // back for a second shot) rather than resetting — resetGradeState() is only
// screen that card is finished, and adding to it is meaningless: the result // for starting a fresh card, which the library button already does.
// view has no photo strip, so renderGradeReview would keep showing the old $('#btn-camera').addEventListener('click', () => $('#grade-camera').click());
// 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-camera').addEventListener('change', (e) => addPickedFrom(e.target));
$('#grade-review').addEventListener('click', (e) => { $('#grade-review').addEventListener('click', (e) => {
if (e.target.closest('#grade-add-more')) { $('#grade-file').click(); return; } if (e.target.closest('#grade-add-more')) { $('#grade-file').click(); return; }
@ -428,23 +398,14 @@ function renderHistory() {
const rows = state.history; const rows = state.history;
$('#history-count').textContent = rows.length ? `${rows.length} graded` : ''; $('#history-count').textContent = rows.length ? `${rows.length} graded` : '';
$('#history-empty').hidden = rows.length > 0; $('#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}"> <tr data-history-row="${g.id}">
<td> <td>
<div class="cardcell"> <div class="cardcell">
${g.thumbnail ? `<img src="${g.thumbnail}" alt="">` : '<span class="thumb-blank"></span>'} ${g.thumbnail ? `<img src="${g.thumbnail}" alt="">` : '<span class="thumb-blank"></span>'}
<div> <div>
<div class="cardcell-name">${esc(g.label || g.card_note || 'Untitled card')}</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)${authBadge ? ' ' + authBadge : ''}</div> <div class="cardcell-meta">${g.card_type ? `${esc(TYPE_LABEL[g.card_type] || 'Card')} · ` : ''}${g.image_count || 1} photo(s)</div>
</div> </div>
</div> </div>
</td> </td>
@ -459,8 +420,7 @@ function renderHistory() {
<button class="btn btn-quiet btn-sm" data-history-delete="${g.id}">Delete</button> <button class="btn btn-quiet btn-sm" data-history-delete="${g.id}">Delete</button>
</div> </div>
</td> </td>
</tr>`; </tr>`).join('');
}).join('');
} }
/* ----------------------------------------------------------------- spend */ /* ----------------------------------------------------------------- spend */

View file

@ -422,53 +422,6 @@ textarea { resize: vertical; min-height: 64px; }
margin-top: 0; margin-bottom: 14px; 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 */
.banner { .banner {

View file

@ -6,7 +6,6 @@ it told you, so you can look back at a card without re-running the estimate.
""" """
import base64 import base64
import binascii
import json import json
import os import os
import sqlite3 import sqlite3
@ -38,7 +37,6 @@ CREATE TABLE IF NOT EXISTS grades (
grade_high INTEGER, grade_high INTEGER,
confidence TEXT, confidence TEXT,
categories_json TEXT, categories_json TEXT,
authenticity_json TEXT,
edge_measurements_json TEXT, edge_measurements_json TEXT,
centering_measurement_json TEXT, centering_measurement_json TEXT,
aspect_measurement_json TEXT, aspect_measurement_json TEXT,
@ -128,7 +126,6 @@ def init():
for statement in ( for statement in (
"ALTER TABLE grades ADD COLUMN aspect_measurement_json TEXT", "ALTER TABLE grades ADD COLUMN aspect_measurement_json TEXT",
"ALTER TABLE grades ADD COLUMN source_images_json TEXT", "ALTER TABLE grades ADD COLUMN source_images_json TEXT",
"ALTER TABLE grades ADD COLUMN authenticity_json TEXT",
): ):
try: try:
conn.execute(statement) conn.execute(statement)
@ -203,24 +200,8 @@ def _decode_images(raw):
items = json.loads(raw) items = json.loads(raw)
except (ValueError, TypeError): except (ValueError, TypeError):
return None return None
# Decoding is inside the guard too, not just the JSON parse: a row return [(base64.standard_b64decode(item["image_base64"]), item.get("filename") or "upload")
# written by an older/newer shape, or truncated, would otherwise raise for item in items]
# 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): def save_grade(grade, thumbnail=None, label=None, source_images=None):
@ -238,12 +219,11 @@ def save_grade(grade, thumbnail=None, label=None, source_images=None):
"(created_at, label, card_type, card_note, image_count, thumbnail, " "(created_at, label, card_type, card_note, image_count, thumbnail, "
" model, estimated_grade, " " model, estimated_grade, "
" grade_low, grade_high, confidence, categories_json, " " grade_low, grade_high, confidence, categories_json, "
" authenticity_json, "
" edge_measurements_json, centering_measurement_json, " " edge_measurements_json, centering_measurement_json, "
" aspect_measurement_json, " " aspect_measurement_json, "
" limitations_json, note, estimated_cost, usage_json, " " limitations_json, note, estimated_cost, usage_json, "
" source_images_json) " " source_images_json) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
( (
now(), label, grade.get("card_type"), grade.get("card_note"), now(), label, grade.get("card_type"), grade.get("card_note"),
grade.get("image_count"), thumbnail, grade.get("image_count"), thumbnail,
@ -251,7 +231,6 @@ def save_grade(grade, thumbnail=None, label=None, source_images=None):
grade.get("estimated_grade"), grade.get("grade_low"), grade.get("estimated_grade"), grade.get("grade_low"),
grade.get("grade_high"), grade.get("confidence"), grade.get("grade_high"), grade.get("confidence"),
json.dumps(grade.get("categories")), json.dumps(grade.get("categories")),
json.dumps(grade.get("authenticity")),
json.dumps(grade.get("edge_measurements")), json.dumps(grade.get("edge_measurements")),
json.dumps(grade.get("centering_measurement")), json.dumps(grade.get("centering_measurement")),
json.dumps(grade.get("aspect_measurement")), json.dumps(grade.get("aspect_measurement")),
@ -285,7 +264,6 @@ def update_grade_result(grade_id, grade, thumbnail=None, source_images=None):
grade.get("estimated_grade"), grade.get("grade_low"), grade.get("estimated_grade"), grade.get("grade_low"),
grade.get("grade_high"), grade.get("confidence"), grade.get("grade_high"), grade.get("confidence"),
json.dumps(grade.get("categories")), json.dumps(grade.get("categories")),
json.dumps(grade.get("authenticity")),
json.dumps(grade.get("edge_measurements")), json.dumps(grade.get("edge_measurements")),
json.dumps(grade.get("centering_measurement")), json.dumps(grade.get("centering_measurement")),
json.dumps(grade.get("aspect_measurement")), json.dumps(grade.get("aspect_measurement")),
@ -296,7 +274,7 @@ def update_grade_result(grade_id, grade, thumbnail=None, source_images=None):
sql = ( sql = (
"UPDATE grades SET created_at=?, card_type=?, card_note=?, " "UPDATE grades SET created_at=?, card_type=?, card_note=?, "
"image_count=?, model=?, estimated_grade=?, grade_low=?, " "image_count=?, model=?, estimated_grade=?, grade_low=?, "
"grade_high=?, confidence=?, categories_json=?, authenticity_json=?, " "grade_high=?, confidence=?, categories_json=?, "
"edge_measurements_json=?, centering_measurement_json=?, " "edge_measurements_json=?, centering_measurement_json=?, "
"aspect_measurement_json=?, limitations_json=?, note=?, " "aspect_measurement_json=?, limitations_json=?, note=?, "
"estimated_cost=?, usage_json=?" "estimated_cost=?, usage_json=?"
@ -426,7 +404,7 @@ def get_grade_images(grade_id):
def _row_to_grade(row): def _row_to_grade(row):
d = dict(row) d = dict(row)
for key in ("categories_json", "authenticity_json", "edge_measurements_json", for key in ("categories_json", "edge_measurements_json",
"centering_measurement_json", "aspect_measurement_json", "centering_measurement_json", "aspect_measurement_json",
"limitations_json", "usage_json"): "limitations_json", "usage_json"):
out_key = key[:-len("_json")] out_key = key[:-len("_json")]
@ -451,10 +429,9 @@ def _row_to_grade(row):
_LIST_COLUMNS = ( _LIST_COLUMNS = (
"id, created_at, label, card_type, card_note, image_count, thumbnail, " "id, created_at, label, card_type, card_note, image_count, thumbnail, "
"model, estimated_grade, grade_low, grade_high, confidence, " "model, estimated_grade, grade_low, grade_high, confidence, "
"categories_json, authenticity_json, edge_measurements_json, " "categories_json, edge_measurements_json, centering_measurement_json, "
"centering_measurement_json, aspect_measurement_json, limitations_json, " "aspect_measurement_json, limitations_json, note, estimated_cost, "
"note, estimated_cost, usage_json, " "usage_json, (source_images_json IS NOT NULL) AS source_images_json"
"(source_images_json IS NOT NULL) AS source_images_json"
) )

181
vision.py
View file

@ -85,14 +85,12 @@ MODELS = {
"adaptive_thinking": False, "adaptive_thinking": False,
"fallbacks": False, "fallbacks": False,
"in_per_mtok": 2.00, "out_per_mtok": 10.00, "in_per_mtok": 2.00, "out_per_mtok": 10.00,
# Calibrated against 8 real single/two-photo grades once this model # A rough estimate, unlike the Anthropic figures (which were true'd
# had actually been used (median 18.4k in / 1.2k out). The initial # up against real usage — see the README's grading-cost note). Only
# 1500 was a guess made before any real call existed and advertised # affects the ADVERTISED per-grade estimate in Settings; the actual
# roughly 2.2x under the true cost — a guess is fine to start from, # billed cost always comes from the real usage this API call
# but it has to be trued up once real usage exists, exactly as the # reports, never from this number.
# Anthropic figures were. "approx_image_tokens": 1500,
"approx_image_tokens": 3560,
"approx_output_tokens": 1180,
}, },
} }
@ -365,21 +363,13 @@ PSA weighs four things. Assess each one separately:
- CENTERING: the ratio of the border widths on opposing sides. PSA publishes \ - 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 \ 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): leniently, 75/25 for a 10 and 90/10 from 9 downwards):
55/45 ............. allows a 10 55/45 to 60/40 .... allows a 10
60/40 ............. allows a 9 60/40 ............. allows a 9
65/35 ............. allows an 8 65/35 ............. allows an 8
70/30 ............. allows a 7 70/30 ............. allows a 7
80/20 ............. allows a 6 80/20 ............. allows a 6
85/15 ............. allows a 5 or 4 85/15 ............. allows a 5 or 4
90/10 ............. allows a 3 or 2 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 \ 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. 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 \ When a MEASURED CENTERING block is supplied, use those figures they are \
@ -531,51 +521,6 @@ the untouched crop too.
On chrome and refractor stock, expect fine scratching; it is the norm rather \ On chrome and refractor stock, expect fine scratching; it is the norm rather \
than the exception, and its absence is what is notable. 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 HOW PSA COMBINES THE FOUR
The grade is capped by the WORST attribute, not averaged across them. Three \ The grade is capped by the WORST attribute, not averaged across them. Three \
@ -702,15 +647,6 @@ 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 \ where you could assess all four categories; "medium" when one or two \
categories are unassessable; "low" when the photo mainly supports identifying \ categories are unassessable; "low" when the photo mainly supports identifying \
the card rather than grading it. 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 \ - limitations: list each specific thing the photo prevented you from checking \
("back not shown, so back centering and back corners are unknown", "resolution \ ("back not shown, so back centering and back corners are unknown", "resolution \
too low to see print lines or light surface scratches"). too low to see print lines or light surface scratches").
@ -734,25 +670,6 @@ GRADE_CATEGORY_SCHEMA = {
"additionalProperties": False, "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 = { GRADING_SCHEMA = {
"type": "object", "type": "object",
"properties": { "properties": {
@ -765,7 +682,6 @@ GRADING_SCHEMA = {
"type": "string", "type": "string",
"description": "One line naming the card if legible (player/name, set, year) — for the history list.", "description": "One line naming the card if legible (player/name, set, year) — for the history list.",
}, },
"authenticity": AUTHENTICITY_SCHEMA,
"estimated_grade": { "estimated_grade": {
"type": ["integer", "null"], "type": ["integer", "null"],
"description": "Best single estimate, whole number 1-10, or null if ungradeable from these photos.", "description": "Best single estimate, whole number 1-10, or null if ungradeable from these photos.",
@ -783,9 +699,9 @@ GRADING_SCHEMA = {
}, },
"note": {"type": "string", "description": "Short overall summary."}, "note": {"type": "string", "description": "Short overall summary."},
}, },
"required": ["card_type", "card_note", "authenticity", "estimated_grade", "required": ["card_type", "card_note", "estimated_grade", "grade_low",
"grade_low", "grade_high", "confidence", "centering", "corners", "grade_high", "confidence", "centering", "corners", "edges",
"edges", "surface", "limitations", "note"], "surface", "limitations", "note"],
"additionalProperties": False, "additionalProperties": False,
} }
@ -804,14 +720,6 @@ def _clean_category(raw):
return {"severity": severity, "observation": raw.get("observation") or ""} 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): 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). """Estimate the PSA grade a card would likely receive, from photo(s).
@ -863,9 +771,6 @@ 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 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 centering = cardimage.centering_profile(images[0][0]) if can_measure else None
aspect = cardimage.aspect_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 = [] prompt_parts = []
if centering and centering.get("reliable") is False: if centering and centering.get("reliable") is False:
@ -959,7 +864,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 " "strip images to describe what the wear looks like and to "
"catch what the measurement does not look for at all, such as " "catch what the measurement does not look for at all, such as "
"a nick, a chip, or a crushed edge.") "a nick, a chip, or a crushed edge.")
if aspect and aspect.get("reliable") is not False: if aspect:
standard_rows = "\n".join( standard_rows = "\n".join(
" {}: {:.4f} standard → {:.1f}% deviation".format( " {}: {:.4f} standard → {:.1f}% deviation".format(
name, data["standard_ratio"], data["deviation_percent"]) name, data["standard_ratio"], data["deviation_percent"])
@ -999,34 +904,6 @@ 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 " "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 " "information the full photo lacked, only easier viewing, so do not "
"read resampling softness as card wear.") "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.") prompt_parts.append("Estimate the PSA grade this trading card would likely receive.")
parsed, usage = _call_vision( parsed, usage = _call_vision(
@ -1043,51 +920,24 @@ def grade_card(images, api_key=None, model=None, effort=None, zoom_details=True)
if card_type not in ("pokemon", "sports", "other_tcg", "other"): if card_type not in ("pokemon", "sports", "other_tcg", "other"):
card_type = "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 { return {
"closeups": len(crops), "closeups": len(crops),
"card_type": card_type, "card_type": card_type,
"card_note": (parsed.get("card_note") or "").strip(), "card_note": (parsed.get("card_note") or "").strip(),
"authenticity": _clean_authenticity(parsed.get("authenticity")),
"edge_measurements": measured, "edge_measurements": measured,
"centering_measurement": centering, "centering_measurement": centering,
"aspect_measurement": aspect, "aspect_measurement": aspect,
"estimated_grade": _clean_grade(parsed.get("estimated_grade")), "estimated_grade": _clean_grade(parsed.get("estimated_grade")),
"grade_low": low, "grade_low": low,
"grade_high": high, "grade_high": high,
"confidence": confidence, "confidence": parsed.get("confidence") or "low",
"categories": { "categories": {
"centering": _clean_category(parsed.get("centering")), "centering": _clean_category(parsed.get("centering")),
"corners": _clean_category(parsed.get("corners")), "corners": _clean_category(parsed.get("corners")),
"edges": _clean_category(parsed.get("edges")), "edges": _clean_category(parsed.get("edges")),
"surface": _clean_category(parsed.get("surface")), "surface": _clean_category(parsed.get("surface")),
}, },
"limitations": limitations, "limitations": [l for l in (parsed.get("limitations") or []) if l],
"note": parsed.get("note") or "", "note": parsed.get("note") or "",
"usage": usage, "usage": usage,
} }
@ -1125,11 +975,8 @@ def price_guide():
guide = {} guide = {}
for model_id, caps in MODELS.items(): for model_id, caps in MODELS.items():
# 1 supplied photo + ~12 generated close-ups, JSON verdict out. # 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_in = caps["approx_image_tokens"] * 5 + 600
est_out = caps.get("approx_output_tokens", 700) est_out = 700
rate_in, rate_out = current_rates(caps) rate_in, rate_out = current_rates(caps)
guide[model_id] = { guide[model_id] = {
"label": caps["label"], "label": caps["label"],