diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 32fe622..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,139 +0,0 @@ -# Card Grader - -Self-hosted web app that estimates PSA trading card grades from photos, using -vision-capable LLMs (Claude and/or GPT) as the grading engine. Runs as a -single Docker container on the user's Unraid home server, exposed at -`hippofam.com/cards` through Nginx Proxy Manager. - -No framework: the whole backend is Python stdlib -(`http.server.ThreadingHTTPServer`) plus `sqlite3`. Optional third-party -packages are `anthropic`, `openai`, and `Pillow` (image processing only — -never required for the server to boot). - -## Architecture - -- **`app.py`** — HTTP routing/handlers. Reads Basic Auth username forwarded - by the reverse proxy for *attribution only* (who graded what), never for - access control — auth itself is enforced by Nginx Proxy Manager in front - of the container via htpasswd. Admin-only settings are gated by - `ADMIN_USERNAME` (`CARD_GRADER_ADMIN_USER` env var). Request bodies are - capped (`MAX_BODY_BYTES`); oversized/malformed bodies get a 413 and the - connection is closed rather than trusting a client-supplied length. -- **`vision.py`** — Provider abstraction over Anthropic and OpenAI vision - APIs (`_call_anthropic` / `_call_openai`, dispatched from a common - entrypoint). `MODELS` holds per-model pricing/capabilities. - `GRADING_SYSTEM` is the large system prompt encoding PSA's actual grading - rubric (centering tolerances, corner/edge/surface criteria, qualifiers — - MC/OC/PD/ST/MK, half-grades, Altered Authentic) — verified against - psacard.com, beckett.com, and cgccards.com, not guessed. Self-reported - "high" confidence is capped to "medium" when neither centering nor edge - whitening was independently measurable from the image geometry. -- **`cardimage.py`** — Pillow-based image pipeline: card boundary detection, - corner/edge/surface crop generation, centering/edge-whitening/aspect-ratio - measurement from pixels (not just left to the model's eye). Includes - holder/toploader-aware detection (`detect_card_box`): a combinatorial - search over candidate edge positions constrained to standard card aspect - ratios, so the app can locate the actual card inside a slab/toploader - rather than just detecting the holder's outline. When a holder is - detected, edge-whitening measurement is refused (the holder's own inner - edge corrupts that specific measurement) but centering still runs - (border-width geometry, unaffected by clear plastic), and aspect-ratio - measurement deliberately uses the *unrefined* box to avoid circularity - (refinement selects for standard-ratio rectangles, so measuring the - refined box's ratio would trivially always look "normal"). -- **`store.py`** — SQLite (WAL mode) persistence. `grades` and - `grade_events` tables, JSON columns for structured data, schema - migrations via idempotent `ALTER TABLE` in `store.init()`. List/detail - API responses explicitly exclude `source_images_json` to keep payloads - small. DB path overridable via `CARD_GRADER_DB_PATH`. -- **`static/`** — Vanilla JS/CSS/HTML frontend, PWA-enabled (manifest, - service worker). "PSA slab" visual theme. `__BASE__` is templated at - request time so the app works correctly when served from a sub-path - (`/cards`) behind the proxy. - -## Local development - -```bash -python3 app.py -``` - -Needs `ANTHROPIC_API_KEY` and/or `OPENAI_API_KEY` in the environment to -actually grade; the server itself has no required third-party deps. A local -`grades.db` is gitignored and is scratch/test data only — it is **not** the -production database (that lives on the server, see below). - -## Deployment - -**Target:** Unraid server, container path `/mnt/user/appdata/card-grader`, -built from `Dockerfile` / `docker-compose.yml` in this repo. Non-root -container user (`99:100` / `nobody:users`), healthcheck, log rotation, -`init: true`. Reverse-proxied by Nginx Proxy Manager at `hippofam.com/cards` -with per-user HTTP Basic Auth. - -**Current deploy mechanism (as of 2026-08-25): manual.** Changes are copied -to the server by hand (`scp`) and the container is rebuilt over SSH; the two -copies of the repo (local Mac, server) are kept in sync by committing on -both sides. There is currently no automated push-to-deploy path connected — -see below. - -**Pending: restricted git-push deploy.** A setup script -(`setup-card-grader-deploy.sh`, delivered to the user, not yet run) creates -a scoped `carddeploy` user on the Unraid box for exactly this purpose: - -- Login shell is `git-shell` — accepts git push/pull only, nothing else (no - interactive shell, no arbitrary commands, no SFTP). -- A `post-receive` hook (root-owned, mode 755 — not writable by the - `carddeploy` account, and not something `git push` can overwrite via the - protocol regardless) triggers a single root-owned deploy script via one - narrowly-scoped `sudo` rule (`NOPASSWD` for that *exact script path* - only — deliberately never raw `docker`/the `docker` group, both of which - are root-equivalent on the whole box via the daemon socket). -- The deploy script does `git checkout -f main` into the live app directory - and `docker compose up -d --build`. -- State (the bare repo, the deploy script, the sudoers source file) lives - under `/mnt/user/appdata/card-grader-deploy/` on the array — Unraid boots - from USB into RAM, so anything living only under `/` would vanish on - reboot. Setup is reinstalled idempotently via `/boot/config/go` (Unraid's - official boot-time persistence hook) so it survives reboots. - -Once the user runs that script and confirms, the local SSH config alias -`unraid-cardgrader` (in `~/.ssh/config`, already pointed at the new -`carddeploy` user and a fresh dedicated keypair -`~/.ssh/id_ed25519_cardgrader_deploy`) will be usable, and the remaining -step is adding the bare repo as a git remote here and doing a first push to -verify the pipeline end-to-end. **Do not assume this pipeline is live** — -confirm with the user before relying on it. - -A prior root SSH key/access to the whole box was explicitly revoked by the -user; the replacement above is intentionally scoped to *only* updating this -one container, not general Unraid/Docker access. - -## Conventions and constraints established for this project - -- PSA grading rules must be verified against a real source (PSA's own site, - Beckett, CGC) before being encoded into `GRADING_SYSTEM` — this rubric has - had real accuracy bugs (e.g. a self-contradictory centering tolerance, a - missing 5% front-centering leeway rule for grades 7+) found and fixed - through actual verification, not assumption. -- The vision prompt's `cannot_assess` escape hatch is deliberately narrow: - surface condition genuinely can't be separated from dust/scratches on a - toploader/slab's plastic, so it keeps the escape hatch. Corner and edge - geometry (sharpness, whitening, chipping) **does** read through clear - plastic and must be judged normally — an earlier prompt version gave the - model an escape hatch for corners/edges too, which caused false - "cannot tell from photo" results on cards that were plainly visible - through the holder. Don't reintroduce that. -- No pre-flight/"precheck" step before a paid grading call. This was tried - and explicitly rejected by the user — their workflow is screenshots that - can't be pulled out of the toploader, so a pre-check step doesn't fit and - was reverted. -- No browser automation (Playwright or otherwise) to scrape eBay listings. - Tried a plain `curl`-based fetch once (not Playwright) and got an - immediate 403; iterating on headers/fingerprinting to get past that would - be bot-detection evasion regardless of which tool performs it, and is - out of scope. This feature is paused; if revisited, the legitimate path - is a client-side bookmarklet/extension that uses the *user's own* - authenticated browser session rather than a server-side fetch. -- Never trust the reverse proxy's forwarded auth header for anything beyond - attribution (whose name to log against a grade) — access control is the - proxy's job, not the app's. diff --git a/app.py b/app.py index 6680216..793a37d 100644 --- a/app.py +++ b/app.py @@ -432,12 +432,6 @@ 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) @@ -459,7 +453,6 @@ 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 c76f4f3..af09b51 100644 --- a/cardimage.py +++ b/cardimage.py @@ -241,213 +241,6 @@ 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. @@ -637,30 +430,7 @@ def edge_wear_profile(image_bytes): img.load() if img.mode not in ("RGB", "L"): img = img.convert("RGB") - 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") + box = _detect_card_box(img) or (0, 0, img.width, img.height) card = img.crop(box).convert("RGB") short = min(card.size) @@ -709,75 +479,22 @@ 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 = {} - 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())) + 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." + ) # Baseline from the non-outlier edges pooled, not each edge against # itself. Whitening only ever raises luma and lowers saturation, so @@ -813,20 +530,17 @@ def edge_wear_profile(image_bytes): # answer is the right outcome there; a wrong number is worse than no # number, because it drags the whole grade down with it. iqr = lumas[int(len(lumas) * 0.75)] - lumas[int(len(lumas) * 0.25)] - reason = margin_reason or whole_card_reason - if reason is None and base_l >= 205 and base_s <= 45: + reason = None + if 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 reason is None and iqr >= 70: + elif 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 (reason is not None or name in outliers) + name: (None if name in outliers else (_score_edge(cols, base_l, base_s) if cols else None)) for name, cols in collected.items() } @@ -921,17 +635,12 @@ def centering_profile(image_bytes): 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) - margin_reason = _box_margin_reason(box, img.size) - card = img.crop(box) + card = img.crop(_detect_card_box(img) or (0, 0, img.width, img.height)) 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 @@ -958,57 +667,25 @@ def centering_profile(image_bytes): } # All four sides must plausibly be the SAME border before a ratio of - # 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: + # 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: 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(best_excl), + "describe centering at all").format(side), } - return { - "reliable": False, - "reason": ("the four sides don't read as one consistent " - "border — they split into at least two different " - "colours/materials with no single side " - "explaining it. This usually means the card is " - "still in a case, slab, or protective holder in " - "the photo, so some sides measured the holder " - "instead of the card. Take it out and reshoot " - "for a centering measurement that means " - "anything."), - } samples = list(side_samples.values()) border_rgb = tuple(sorted(c[i] for c in samples)[len(samples) // 2] @@ -1092,41 +769,12 @@ 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 = { @@ -1139,7 +787,6 @@ 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, @@ -1249,8 +896,7 @@ def detail_crops(image_bytes, filename="card.jpg", corners=True, edges=True, if max(img.size) < MIN_SOURCE_PX: return [] - box, _refined = detect_card_box(img) - box = box or (0, 0, img.width, img.height) + box = _detect_card_box(img) 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 08360fb..53010db 100644 --- a/static/app.js +++ b/static/app.js @@ -157,8 +157,7 @@ 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 && g.aspect_measurement.reliable !== false) - ? g.aspect_measurement : null; + const am = 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) @@ -183,16 +182,7 @@ 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.
@@ -331,22 +321,9 @@ 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, room); + const picked = await readPickedImages(input, 6 - gradeState.files.length); if (!picked.length) return; gradeState.files.push(...picked); renderGradeReview(); @@ -362,17 +339,10 @@ $('#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. 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(); -}); +// 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()); $('#grade-camera').addEventListener('change', (e) => addPickedFrom(e.target)); $('#grade-review').addEventListener('click', (e) => { if (e.target.closest('#grade-add-more')) { $('#grade-file').click(); return; } @@ -428,23 +398,14 @@ 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) => { - 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 ` + $('#history-body').innerHTML = rows.map((g) => `
${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)${authBadge ? ' ' + authBadge : ''}
+
${g.card_type ? `${esc(TYPE_LABEL[g.card_type] || 'Card')} · ` : ''}${g.image_count || 1} photo(s)
@@ -459,8 +420,7 @@ function renderHistory() { - `; - }).join(''); + `).join(''); } /* ----------------------------------------------------------------- spend */ diff --git a/static/style.css b/static/style.css index 9fcb2b3..b3f6ef7 100644 --- a/static/style.css +++ b/static/style.css @@ -422,53 +422,6 @@ 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 475326c..2098a00 100644 --- a/store.py +++ b/store.py @@ -6,7 +6,6 @@ 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 @@ -38,7 +37,6 @@ 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, @@ -128,7 +126,6 @@ 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) @@ -203,24 +200,8 @@ def _decode_images(raw): items = json.loads(raw) except (ValueError, TypeError): return None - # 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 + return [(base64.standard_b64decode(item["image_base64"]), item.get("filename") or "upload") + for item in items] def save_grade(grade, thumbnail=None, label=None, source_images=None): @@ -238,12 +219,11 @@ def save_grade(grade, thumbnail=None, label=None, source_images=None): "(created_at, label, card_type, card_note, image_count, thumbnail, " " 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, @@ -251,7 +231,6 @@ def save_grade(grade, thumbnail=None, label=None, source_images=None): grade.get("estimated_grade"), grade.get("grade_low"), grade.get("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")), @@ -285,7 +264,6 @@ def update_grade_result(grade_id, grade, thumbnail=None, source_images=None): grade.get("estimated_grade"), grade.get("grade_low"), grade.get("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")), @@ -296,7 +274,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=?, authenticity_json=?, " + "grade_high=?, confidence=?, categories_json=?, " "edge_measurements_json=?, centering_measurement_json=?, " "aspect_measurement_json=?, limitations_json=?, note=?, " "estimated_cost=?, usage_json=?" @@ -426,7 +404,7 @@ def get_grade_images(grade_id): def _row_to_grade(row): d = dict(row) - for key in ("categories_json", "authenticity_json", "edge_measurements_json", + for key in ("categories_json", "edge_measurements_json", "centering_measurement_json", "aspect_measurement_json", "limitations_json", "usage_json"): out_key = key[:-len("_json")] @@ -451,10 +429,9 @@ 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, 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" + "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" ) diff --git a/vision.py b/vision.py index 2a20113..e93693c 100644 --- a/vision.py +++ b/vision.py @@ -85,14 +85,12 @@ MODELS = { "adaptive_thinking": False, "fallbacks": False, "in_per_mtok": 2.00, "out_per_mtok": 10.00, - # 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, + # 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, }, } @@ -365,21 +363,13 @@ 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 ............. allows a 10 + 55/45 to 60/40 .... 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 \ @@ -531,51 +521,6 @@ 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 \ @@ -702,15 +647,6 @@ centering, that range should be genuinely wide (e.g. 6-9), not cosmetic. where you could assess all four categories; "medium" when one or two \ 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"). @@ -734,25 +670,6 @@ 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": { @@ -765,7 +682,6 @@ 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.", @@ -783,9 +699,9 @@ GRADING_SCHEMA = { }, "note": {"type": "string", "description": "Short overall summary."}, }, - "required": ["card_type", "card_note", "authenticity", "estimated_grade", - "grade_low", "grade_high", "confidence", "centering", "corners", - "edges", "surface", "limitations", "note"], + "required": ["card_type", "card_note", "estimated_grade", "grade_low", + "grade_high", "confidence", "centering", "corners", "edges", + "surface", "limitations", "note"], "additionalProperties": False, } @@ -804,14 +720,6 @@ 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). @@ -863,9 +771,6 @@ def grade_card(images, api_key=None, model=None, effort=None, zoom_details=True) measured = cardimage.edge_wear_profile(images[0][0]) if can_measure else None 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: @@ -959,7 +864,7 @@ def grade_card(images, api_key=None, model=None, effort=None, zoom_details=True) "strip images to describe what the wear looks like and to " "catch what the measurement does not look for at all, such as " "a nick, a chip, or a crushed edge.") - if aspect and aspect.get("reliable") is not False: + if aspect: standard_rows = "\n".join( " {}: {:.4f} standard → {:.1f}% deviation".format( name, data["standard_ratio"], data["deviation_percent"]) @@ -999,34 +904,6 @@ def grade_card(images, api_key=None, model=None, effort=None, zoom_details=True) "when you say which corner or edge a problem is on. They add no " "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( @@ -1043,51 +920,24 @@ def grade_card(images, api_key=None, model=None, effort=None, zoom_details=True) if card_type not in ("pokemon", "sports", "other_tcg", "other"): 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": confidence, + "confidence": parsed.get("confidence") or "low", "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": limitations, + "limitations": [l for l in (parsed.get("limitations") or []) if l], "note": parsed.get("note") or "", "usage": usage, } @@ -1125,11 +975,8 @@ 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 = caps.get("approx_output_tokens", 700) + est_out = 700 rate_in, rate_out = current_rates(caps) guide[model_id] = { "label": caps["label"],