Compare commits
10 commits
034761e145
...
bc6ee2cf27
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc6ee2cf27 | ||
|
|
22bb14e5d9 | ||
|
|
3510f637ba | ||
|
|
811c16f334 | ||
|
|
0cbeb26665 | ||
|
|
6c612c1f4a | ||
|
|
deabe74593 | ||
|
|
3cacf2f292 | ||
|
|
2b3bbaf40f | ||
|
|
d026cce91a |
7 changed files with 828 additions and 65 deletions
139
CLAUDE.md
Normal file
139
CLAUDE.md
Normal 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
7
app.py
|
|
@ -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"],
|
||||
|
|
|
|||
418
cardimage.py
418
cardimage.py
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
37
store.py
37
store.py
|
|
@ -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
181
vision.py
|
|
@ -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"],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue