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
|
# 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)
|
||||||
|
|
@ -453,6 +459,7 @@ 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"],
|
||||||
|
|
|
||||||
422
cardimage.py
422
cardimage.py
|
|
@ -241,6 +241,213 @@ 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.
|
||||||
|
|
||||||
|
|
@ -430,7 +637,30 @@ 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 = _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")
|
card = img.crop(box).convert("RGB")
|
||||||
|
|
||||||
short = min(card.size)
|
short = min(card.size)
|
||||||
|
|
@ -479,22 +709,75 @@ 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 = {}
|
||||||
for name, (l_med, s_med) in edge_medians.items():
|
whole_card_reason = None
|
||||||
others = [v for n, v in edge_medians.items() if n != name]
|
if len(edge_medians) == 4:
|
||||||
if len(others) < 2:
|
names = list(edge_medians)
|
||||||
continue
|
lumas_by_edge = {n: edge_medians[n][0] for n in names}
|
||||||
other_l = sorted(v[0] for v in others)[len(others) // 2]
|
overall_spread = max(lumas_by_edge.values()) - min(lumas_by_edge.values())
|
||||||
other_s = sorted(v[1] for v in others)[len(others) // 2]
|
# 60 is comfortably above ordinary lighting/exposure variation
|
||||||
if (l_med - other_l) >= 45 and (other_s - s_med) >= 35:
|
# across a card's four sides (seen in practice: 10-30) and well
|
||||||
outliers[name] = (
|
# below a genuine different-material gap (150+ for a die-cut
|
||||||
"this edge's finish reads as a different material from "
|
# window or a case's plastic against real cardstock). No
|
||||||
"the card's other edges (much brighter and less "
|
# saturation requirement here, unlike the old version of this
|
||||||
"saturated) — likely a clear acetate window, a die-cut "
|
# check: a black case against a white or cream border is a huge
|
||||||
"insert, or a foil accent on this side only, not "
|
# luma gap with barely any saturation signal at all, since
|
||||||
"whitening. A paper-showing-through measurement doesn't "
|
# neither material has real colour to lose.
|
||||||
"apply to a material that was never opaque to begin with."
|
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
|
# 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
|
||||||
|
|
@ -530,17 +813,20 @@ 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 = None
|
reason = margin_reason or whole_card_reason
|
||||||
if base_l >= 205 and base_s <= 45:
|
if reason is None and 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 iqr >= 70:
|
elif reason is None and 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 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))
|
else (_score_edge(cols, base_l, base_s) if cols else None))
|
||||||
for name, cols in collected.items()
|
for name, cols in collected.items()
|
||||||
}
|
}
|
||||||
|
|
@ -635,12 +921,17 @@ 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()
|
||||||
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")
|
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
|
||||||
|
|
@ -667,25 +958,57 @@ 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. A side whose colour sits far from the
|
# their widths means anything. Checking each side against the median
|
||||||
# median of the other three is a different material — a clear die-cut
|
# of the other three, one at a time, and stopping at the first hit
|
||||||
# window showing the background, a foil accent strip — and one such
|
# was tried first — and silently mishandled the case that matters
|
||||||
# side invalidates the whole geometric premise, not just its own
|
# most: a card still in a black case, where left/right sample the
|
||||||
# number: the walk inward on every side keys off one shared
|
# case (dark) and top/bottom sample the real card border (bright).
|
||||||
# border_rgb. Refuse with the reason rather than return a ratio.
|
# That's a 2-vs-2 split, not one outlier — comparing "left" against
|
||||||
for side, colour in side_samples.items():
|
# the median of {right, top, bottom} lands on a bright value (2 of
|
||||||
others = [v for s, v in side_samples.items() if s != side]
|
# those 3 are bright), so left alone trips the check, the loop
|
||||||
med = tuple(sorted(o[i] for o in others)[len(others) // 2]
|
# returns immediately, and "right" — equally case, equally wrong —
|
||||||
for i in range(3))
|
# is never even examined, so the message blames one side for a
|
||||||
if sum(abs(colour[i] - med[i]) for i in range(3)) > 150:
|
# 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 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(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())
|
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]
|
||||||
|
|
@ -769,12 +1092,41 @@ 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 = {
|
||||||
|
|
@ -787,6 +1139,7 @@ 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,
|
||||||
|
|
@ -896,7 +1249,8 @@ 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 = _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)
|
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]
|
||||||
|
|
|
||||||
|
|
@ -157,7 +157,8 @@ 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 || null;
|
const am = (g.aspect_measurement && g.aspect_measurement.reliable !== false)
|
||||||
|
? 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)
|
||||||
|
|
@ -182,7 +183,16 @@ 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>
|
||||||
|
|
@ -321,9 +331,22 @@ 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, 6 - gradeState.files.length);
|
const picked = await readPickedImages(input, room);
|
||||||
if (!picked.length) return;
|
if (!picked.length) return;
|
||||||
gradeState.files.push(...picked);
|
gradeState.files.push(...picked);
|
||||||
renderGradeReview();
|
renderGradeReview();
|
||||||
|
|
@ -339,10 +362,17 @@ $('#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 — resetGradeState() is only
|
// back for a second shot) rather than resetting. But once a result is on
|
||||||
// for starting a fresh card, which the library button already does.
|
// screen that card is finished, and adding to it is meaningless: the result
|
||||||
$('#btn-camera').addEventListener('click', () => $('#grade-camera').click());
|
// 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-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; }
|
||||||
|
|
@ -398,14 +428,23 @@ 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)</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>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -420,7 +459,8 @@ 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>`).join('');
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ----------------------------------------------------------------- spend */
|
/* ----------------------------------------------------------------- spend */
|
||||||
|
|
|
||||||
|
|
@ -422,6 +422,53 @@ 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 {
|
||||||
|
|
|
||||||
39
store.py
39
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 base64
|
||||||
|
import binascii
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
@ -37,6 +38,7 @@ 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,
|
||||||
|
|
@ -126,6 +128,7 @@ 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)
|
||||||
|
|
@ -200,8 +203,24 @@ def _decode_images(raw):
|
||||||
items = json.loads(raw)
|
items = json.loads(raw)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return None
|
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
|
||||||
for item in items]
|
# 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):
|
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, "
|
"(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,
|
||||||
|
|
@ -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("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")),
|
||||||
|
|
@ -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("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")),
|
||||||
|
|
@ -274,7 +296,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=?, "
|
"grade_high=?, confidence=?, categories_json=?, authenticity_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=?"
|
||||||
|
|
@ -404,7 +426,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", "edge_measurements_json",
|
for key in ("categories_json", "authenticity_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")]
|
||||||
|
|
@ -429,9 +451,10 @@ 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, edge_measurements_json, centering_measurement_json, "
|
"categories_json, authenticity_json, edge_measurements_json, "
|
||||||
"aspect_measurement_json, limitations_json, note, estimated_cost, "
|
"centering_measurement_json, aspect_measurement_json, limitations_json, "
|
||||||
"usage_json, (source_images_json IS NOT NULL) AS source_images_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,
|
"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,
|
||||||
# A rough estimate, unlike the Anthropic figures (which were true'd
|
# Calibrated against 8 real single/two-photo grades once this model
|
||||||
# up against real usage — see the README's grading-cost note). Only
|
# had actually been used (median 18.4k in / 1.2k out). The initial
|
||||||
# affects the ADVERTISED per-grade estimate in Settings; the actual
|
# 1500 was a guess made before any real call existed and advertised
|
||||||
# billed cost always comes from the real usage this API call
|
# roughly 2.2x under the true cost — a guess is fine to start from,
|
||||||
# reports, never from this number.
|
# but it has to be trued up once real usage exists, exactly as the
|
||||||
"approx_image_tokens": 1500,
|
# 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 \
|
- 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 to 60/40 .... allows a 10
|
55/45 ............. 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 \
|
||||||
|
|
@ -521,6 +531,51 @@ 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 \
|
||||||
|
|
@ -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 \
|
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").
|
||||||
|
|
@ -670,6 +734,25 @@ 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": {
|
||||||
|
|
@ -682,6 +765,7 @@ 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.",
|
||||||
|
|
@ -699,9 +783,9 @@ GRADING_SCHEMA = {
|
||||||
},
|
},
|
||||||
"note": {"type": "string", "description": "Short overall summary."},
|
"note": {"type": "string", "description": "Short overall summary."},
|
||||||
},
|
},
|
||||||
"required": ["card_type", "card_note", "estimated_grade", "grade_low",
|
"required": ["card_type", "card_note", "authenticity", "estimated_grade",
|
||||||
"grade_high", "confidence", "centering", "corners", "edges",
|
"grade_low", "grade_high", "confidence", "centering", "corners",
|
||||||
"surface", "limitations", "note"],
|
"edges", "surface", "limitations", "note"],
|
||||||
"additionalProperties": False,
|
"additionalProperties": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -720,6 +804,14 @@ 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).
|
||||||
|
|
||||||
|
|
@ -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
|
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:
|
||||||
|
|
@ -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 "
|
"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:
|
if aspect and aspect.get("reliable") is not False:
|
||||||
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"])
|
||||||
|
|
@ -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 "
|
"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(
|
||||||
|
|
@ -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"):
|
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": parsed.get("confidence") or "low",
|
"confidence": confidence,
|
||||||
"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": [l for l in (parsed.get("limitations") or []) if l],
|
"limitations": limitations,
|
||||||
"note": parsed.get("note") or "",
|
"note": parsed.get("note") or "",
|
||||||
"usage": usage,
|
"usage": usage,
|
||||||
}
|
}
|
||||||
|
|
@ -975,8 +1125,11 @@ 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 = 700
|
est_out = caps.get("approx_output_tokens", 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"],
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue