diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..32fe622 --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/app.py b/app.py index 793a37d..6680216 100644 --- a/app.py +++ b/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"], diff --git a/cardimage.py b/cardimage.py index af09b51..c76f4f3 100644 --- a/cardimage.py +++ b/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,25 +958,57 @@ 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] - for i in range(3)) - if sum(abs(colour[i] - med[i]) for i in range(3)) > 150: + # 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 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()) border_rgb = tuple(sorted(c[i] for c in samples)[len(samples) // 2] @@ -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] diff --git a/static/app.js b/static/app.js index 53010db..08360fb 100644 --- a/static/app.js +++ b/static/app.js @@ -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 = {}) { `; }).join(''); + const auth = g.authenticity || null; + const authBanner = (auth && auth.flag !== 'none') ? ` +
+
${auth.flag === 'likely_not_genuine' + ? 'Possibly not a genuine card' : 'Worth double-checking'}
+ ${auth.observation ? `
${esc(auth.observation)}
` : ''} +
` : ''; + return ` + ${authBanner}
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.
@@ -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') + ? `${ + esc(auth.flag === 'likely_not_genuine' ? 'Possibly fake' : 'Verify')}` + : ''; + return `
${g.thumbnail ? `` : ''}
${esc(g.label || g.card_note || 'Untitled card')}
-
${g.card_type ? `${esc(TYPE_LABEL[g.card_type] || 'Card')} · ` : ''}${g.image_count || 1} photo(s)
+
${g.card_type ? `${esc(TYPE_LABEL[g.card_type] || 'Card')} · ` : ''}${g.image_count || 1} photo(s)${authBadge ? ' ' + authBadge : ''}
@@ -420,7 +459,8 @@ function renderHistory() { - `).join(''); + `; + }).join(''); } /* ----------------------------------------------------------------- spend */ diff --git a/static/style.css b/static/style.css index b3f6ef7..9fcb2b3 100644 --- a/static/style.css +++ b/static/style.css @@ -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 { diff --git a/store.py b/store.py index 2098a00..475326c 100644 --- a/store.py +++ b/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") - for item in items] + # 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" ) diff --git a/vision.py b/vision.py index e93693c..2a20113 100644 --- a/vision.py +++ b/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"],