From d026cce91a097889653f5744b5147412a6d78920 Mon Sep 17 00:00:00 2001 From: Barely Removable Date: Sun, 23 Aug 2026 08:05:51 -0700 Subject: [PATCH 01/10] Refuse edge/centering measurement when box detection found no real margin A card photographed still inside a black display case exposed this: the case filled the entire frame with no background anywhere, so _detect_card_box returned a box at essentially zero margin from the photo's own edges (0px left, 1px right on a 986px-wide photo). Every downstream measurement band -- a few percent of the card's own short dimension, by design, since real wear lives in the outermost sliver -- then sampled entirely within the case's embossed plastic texture, never reaching the actual card. That texture read as 98.9% edge whitening on two sides. The per-edge material-consistency check added for the earlier die-cut fix didn't catch this: the case is uniformly dark on all four sides at the shallow sampling depth used, so no edge disagreed with the others -- the false signal came from local texture noise within one uniform (wrong) material, not a mismatch between materials. This needed a different, earlier check: whether box detection could plausibly have found the real card boundary at all, gated on margin as a fraction of the photo before any per-edge analysis runs. Also fixed a related bug in centering_profile's existing die-cut check: it compared each side against the median of the other three and returned on the FIRST hit, so a genuine 2-vs-2 split (both left and right reading the case, both top and bottom reading the real card) blamed a single side and never even examined whether the second was equally wrong. Replaced with a best-single-exclusion search that correctly distinguishes a true one-side outlier from an unexplainable split. Verified against the actual photo that exposed this (card in a scalloped black case) -- both now correctly refuse instead of measuring the case. Regression-tested: normal photos with reasonable margin, and the earlier die-cut single-outlier case, are both unaffected. --- cardimage.py | 203 +++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 171 insertions(+), 32 deletions(-) diff --git a/cardimage.py b/cardimage.py index af09b51..397c002 100644 --- a/cardimage.py +++ b/cardimage.py @@ -241,6 +241,52 @@ def _detect_card_box(img): return 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 _surface_map(piece): """A band-pass view that isolates surface texture from the artwork. @@ -431,6 +477,7 @@ def edge_wear_profile(image_bytes): if img.mode not in ("RGB", "L"): img = img.convert("RGB") box = _detect_card_box(img) or (0, 0, img.width, img.height) + margin_reason = _box_margin_reason(box, img.size) card = img.crop(box).convert("RGB") short = min(card.size) @@ -479,22 +526,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 +630,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 +738,16 @@ 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 = _detect_card_box(img) 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 +774,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] From 2b3bbaf40ff483ef3b68dc2dfe47886f2e9c35c9 Mon Sep 17 00:00:00 2001 From: Barely Removable Date: Sun, 23 Aug 2026 08:19:58 -0700 Subject: [PATCH 02/10] Add a structured authenticity signal with a distinct banner, instead of burying it in card_note The Charizard grade that prompted this had the model writing 'possible non-standard/proxy print' directly into card_note -- the only free-text field available -- since there was nowhere else for that observation to go. That's why it got no visual treatment: the UI renders card_note as a plain grey subtitle, identical to any ordinary card name. Added authenticity as its own top-level field (flag: none/worth_checking/ likely_not_genuine, plus an observation) alongside the four grading categories but explicitly separate from them -- this is about whether the card is a genuine product at all, not its condition, and PSA authenticates before it grades, so a flagged card's estimated_grade goes to null rather than a number. Prompted with concrete tells for both classes of concern: fan-made/proxy prints (fictional sets, non-existent number combos, home-printer texture, 'proxy' watermarks) and counterfeits of real cards (colour/font/holo mismatches against genuine copies). Trimming (already covered under CORNERS from an earlier fix) now also sets this field when flagged, so the UI has one place to check regardless of which specific issue triggered it. UI: a full banner (not a hint line) above the slab -- critical red for likely_not_genuine, warning amber for worth_checking -- plus a matching dot next to the card name in History so it's visible without opening the card. Both colours reuse --critical/--warning rather than the brand red, since this is exactly the 'distrust this' signal those are already reserved for. Migration tested against the live schema shape; existing rows get authenticity=None and render with no banner, as before. --- app.py | 1 + static/app.js | 24 +++++++++++++-- static/style.css | 37 ++++++++++++++++++++++ store.py | 18 +++++++---- vision.py | 80 ++++++++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 148 insertions(+), 12 deletions(-) diff --git a/app.py b/app.py index 793a37d..f6cea30 100644 --- a/app.py +++ b/app.py @@ -453,6 +453,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/static/app.js b/static/app.js index 53010db..522d18e 100644 --- a/static/app.js +++ b/static/app.js @@ -182,7 +182,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.
@@ -398,13 +407,21 @@ 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 dot rather than repeating the full banner text — the row is + // already tight, and opening the card gives the real explanation. + const authDot = (auth && auth.flag !== 'none') + ? `` + : ''; + return `
${g.thumbnail ? `` : ''}
-
${esc(g.label || g.card_note || 'Untitled card')}
+
${authDot}${esc(g.label || g.card_note || 'Untitled card')}
${g.card_type ? `${esc(TYPE_LABEL[g.card_type] || 'Card')} · ` : ''}${g.image_count || 1} photo(s)
@@ -420,7 +437,8 @@ function renderHistory() { - `).join(''); + `; + }).join(''); } /* ----------------------------------------------------------------- spend */ diff --git a/static/style.css b/static/style.css index b3f6ef7..a472f91 100644 --- a/static/style.css +++ b/static/style.css @@ -422,6 +422,43 @@ 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 version: a plain dot, since the row has no room for the full + banner text and opening the card gives the real explanation. */ +.authenticity-dot { + display: inline-block; width: 8px; height: 8px; border-radius: 50%; + margin-right: 6px; vertical-align: middle; + background: var(--critical); +} +.authenticity-dot.authenticity-check { background: var(--warning); } + /* -------------------------------------------------------------- banner */ .banner { diff --git a/store.py b/store.py index 2098a00..7e0643a 100644 --- a/store.py +++ b/store.py @@ -37,6 +37,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 +127,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) @@ -219,11 +221,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 +234,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 +268,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 +279,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 +409,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 +434,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..f02dec0 100644 --- a/vision.py +++ b/vision.py @@ -521,6 +521,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 \ @@ -670,6 +715,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 +746,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 +764,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 +785,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). @@ -924,6 +997,7 @@ def grade_card(images, api_key=None, model=None, effort=None, zoom_details=True) "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, From 3cacf2f292b066b17ec88d86d8896c6d189209b4 Mon Sep 17 00:00:00 2001 From: Barely Removable Date: Sun, 23 Aug 2026 11:43:36 -0700 Subject: [PATCH 03/10] Cap self-reported 'high' confidence when neither centering nor edge whitening was actually measured Confidence was entirely the model's own self-report, based only on photo sharpness and category coverage -- it had no reliable way to account for whether centering/edge whitening came from a real pixel measurement or from its own eye, even though the app treats that distinction as a big deal everywhere else (it's the whole reason cardimage.py exists). A sharp, well-lit photo of a foil or die-cut card could claim 'high' while actually running on eye-only judgment for two of PSA's four categories. Enforced in code rather than left to the prompt alone -- this is an objective, checkable fact (did centering_profile/edge_wear_profile return a reliable result or not), exactly the kind of thing that shouldn't depend on the model correctly weighing one more instruction among many. Only caps high->medium when NEITHER measurement is available; one of two still measured is left to the model's own judgment, since that's a real legitimate basis for confidence. Also updated the prompt itself so the model's own reasoning stays consistent with what gets displayed, rather than silently contradicting its own prose. Tested all five branches (neither/both/one/one-unreliable measured, and confirmed no spurious limitation text when the model already said medium). --- vision.py | 39 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/vision.py b/vision.py index f02dec0..0481e44 100644 --- a/vision.py +++ b/vision.py @@ -692,6 +692,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"). @@ -993,6 +1002,32 @@ 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, @@ -1004,14 +1039,14 @@ def grade_card(images, api_key=None, model=None, effort=None, zoom_details=True) "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, } From deabe74593afdfc5354f564dd15e883751caa3bf Mon Sep 17 00:00:00 2001 From: Barely Removable Date: Sun, 23 Aug 2026 11:55:29 -0700 Subject: [PATCH 04/10] Replace History-row authenticity dot with a visible badge A 8px dot next to the card name only rewards someone who already suspects a card and goes looking for it -- the badge needs to be the thing that catches your eye scanning the home-screen list itself. Same red/amber tiers as the detail-view banner, using the existing .pill shape so it sits naturally next to the photo-count meta line rather than needing new layout. --- static/app.js | 15 ++++++++------- static/style.css | 24 +++++++++++++++++------- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/static/app.js b/static/app.js index 522d18e..0d8c7e1 100644 --- a/static/app.js +++ b/static/app.js @@ -409,11 +409,12 @@ function renderHistory() { $('#history-empty').hidden = rows.length > 0; $('#history-body').innerHTML = rows.map((g) => { const auth = g.authenticity; - // A dot rather than repeating the full banner text — the row is - // already tight, and opening the card gives the real explanation. - const authDot = (auth && auth.flag !== 'none') - ? `` + // 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 ` @@ -421,8 +422,8 @@ function renderHistory() {
${g.thumbnail ? `` : ''}
-
${authDot}${esc(g.label || g.card_note || 'Untitled card')}
-
${g.card_type ? `${esc(TYPE_LABEL[g.card_type] || 'Card')} · ` : ''}${g.image_count || 1} photo(s)
+
${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 : ''}
diff --git a/static/style.css b/static/style.css index a472f91..9fcb2b3 100644 --- a/static/style.css +++ b/static/style.css @@ -450,14 +450,24 @@ textarea { resize: vertical; min-height: 64px; } font-size: 12.5px; color: var(--ink-2); margin-top: 3px; line-height: 1.5; } -/* History-row version: a plain dot, since the row has no room for the full - banner text and opening the card gives the real explanation. */ -.authenticity-dot { - display: inline-block; width: 8px; height: 8px; border-radius: 50%; - margin-right: 6px; vertical-align: middle; - background: var(--critical); +/* 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); } -.authenticity-dot.authenticity-check { background: var(--warning); } /* -------------------------------------------------------------- banner */ From 6c612c1f4a5228a45cd1d9f0da47c8e38fe67858 Mon Sep 17 00:00:00 2001 From: Barely Removable Date: Tue, 25 Aug 2026 08:00:48 -0700 Subject: [PATCH 05/10] Audit fixes: mis-framed crops, GPT cost 2.2x low, camera button losing photos 1. detail_crops had no equivalent of the margin guard the measurements got. When box detection fails (card still in a case), every crop is cut relative to the wrong rectangle -- verified the 'TOP-LEFT CORNER' close-up of the cased Ohtani is actually the CASE's corner bracket. The measurements refuse and explain; the crops kept being produced and captioned authoritatively, and the prompt tells the model to judge corners/edges/surface *from* them. Now surfaces a framing caveat telling the model to locate the real card edge inside each crop and say cannot_assess rather than grade the holder. 2. gpt-5.6-sol's approx_image_tokens was a pre-launch guess (1500) that advertised ~2.2x under true cost across all 9 real calls. Recalibrated to 3560 against median real usage, and added a per-model output estimate since GPT writes ~1.2k tokens of verdict vs Sonnet's ~0.9k. Both models now advertise within ~3% of observed cost. 3. 'Take photo' didn't reset after a completed grade, unlike 'Choose from library'. The result view has no photo strip, so new photos piled up invisibly behind the old verdict, silently, to the 6-photo cap. Also fixed the cap itself being a silent no-op with no explanation. --- cardimage.py | 25 +++++++++++++++++++++++++ static/app.js | 30 +++++++++++++++++++++++++----- vision.py | 36 +++++++++++++++++++++++++++++------- 3 files changed, 79 insertions(+), 12 deletions(-) diff --git a/cardimage.py b/cardimage.py index 397c002..6583554 100644 --- a/cardimage.py +++ b/cardimage.py @@ -287,6 +287,31 @@ def _box_margin_reason(box, img_size): 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 = _detect_card_box(img) 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. diff --git a/static/app.js b/static/app.js index 0d8c7e1..462947c 100644 --- a/static/app.js +++ b/static/app.js @@ -330,9 +330,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(); @@ -348,10 +361,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; } diff --git a/vision.py b/vision.py index 0481e44..ce8e8ac 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, }, } @@ -853,6 +855,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: @@ -986,6 +991,20 @@ 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( + "IMPORTANT CAVEAT ON THOSE CROPS: {} Because the crop " + "rectangle is derived from that same detection, each close-up " + "is cut relative to the wrong boundary — a corner close-up may " + "well be showing the CORNER OF A CASE, SLEEVE OR HOLDER with " + "the card's own corner sitting somewhere inside the frame, or " + "out of it. Do not assume the crop's own edge is the card's " + "edge. Find the actual card edge inside each close-up first " + "and judge only that; if a given close-up doesn't clearly " + "contain the card's real corner or edge, say cannot_assess " + "for that category rather than grading the holder. Damage, " + "scuffing or whitening on a case is not damage to the " + "card.".format(framing)) prompt_parts.append("Estimate the PSA grade this trading card would likely receive.") parsed, usage = _call_vision( @@ -1084,8 +1103,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"], From 0cbeb2666539414dc0bf5cddf174d5f268541cca Mon Sep 17 00:00:00 2001 From: Barely Removable Date: Tue, 25 Aug 2026 08:05:33 -0700 Subject: [PATCH 06/10] Audit round 2: aspect measured the case too; defensive image decode; own-key state reset 4. aspect_profile had the same root cause as edges/centering but no guard. On the cased photo it reported 5.8% off standard -- the CASE's proportions -- and the UI would surface that as a trimming hint about a card it never measured. This one is more directly load-bearing than the other two, since the measurement IS the detected box. Now refuses, with both consumers (prompt block and UI hint) checking reliable. 5. _decode_images could KeyError/raise out of a plain read on a malformed row, and separately b64decode returns b'' rather than raising on some corrupt input -- which would have fed a zero-byte 'photo' into grading to fail confusingly deep in the pipeline. Both now degrade to the existing re-pick path. 6. _last_used_own_key is instance state on a handler that serves every request on a keep-alive connection, so it outlives the request that set it. Currently safe (every logging path assigns first), but a future path that logged without reaching the assignment would bill the previous request's payer. Cleared up-front now. Full regression suite re-run: die-cut exclusion, uniform cards, centering, aspect, cased-photo refusals, and the store layer all still behave. --- app.py | 6 ++++++ cardimage.py | 11 +++++++++++ static/app.js | 3 ++- store.py | 21 +++++++++++++++++++-- vision.py | 2 +- 5 files changed, 39 insertions(+), 4 deletions(-) diff --git a/app.py b/app.py index f6cea30..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) diff --git a/cardimage.py b/cardimage.py index 6583554..6b3fc36 100644 --- a/cardimage.py +++ b/cardimage.py @@ -939,6 +939,16 @@ def aspect_profile(image_bytes): 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} ratio = min(bw, bh) / float(max(bw, bh)) against_standards = { @@ -951,6 +961,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, diff --git a/static/app.js b/static/app.js index 462947c..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) diff --git a/store.py b/store.py index 7e0643a..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 @@ -202,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): diff --git a/vision.py b/vision.py index ce8e8ac..3da35a8 100644 --- a/vision.py +++ b/vision.py @@ -951,7 +951,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"]) From 811c16f3347162aa6b0f8ffa33ee0b5a30b5bd3d Mon Sep 17 00:00:00 2001 From: Barely Removable Date: Tue, 25 Aug 2026 08:19:00 -0700 Subject: [PATCH 07/10] Correct PSA 10 centering tolerance; encode the 5% leeway rule properly Verified the encoded rubric against psacard.com/gradingstandards directly (the page 403s automated fetchers, so read via a real browser session, clicking through all 14 grade-definition slides). The app said '55/45 to 60/40 allows a 10'. PSA publishes a flat 55/45 for a 10 -- and the app's very next line already said 60/40 allows a 9, so the range was internally contradictory as well as over-lenient: a 58/42 card would read as 10-eligible when PSA would cap it at 9. The range appears to have been a garbled memory of a real rule the app otherwise didn't encode at all: PSA grants a 5% leeway on front centering minimums for cards grading 7 or better. The old text applied that leeway to the 10 only and to no other grade. Now the published tolerances stand as written and the leeway is stated once, for the grades it actually covers, as grader discretion keyed to eye appeal rather than a hard second tolerance -- so a borderline card gets a range instead of a confident single grade. Everything else checked out exactly: 9=60/40, 8=65/35, 7=70/30, 6=80/20, 5/4=85/15, 3/2=90/10, and back 75/25 for a 10 / 90/10 from 9 down. PSA's 'Altered Authentic' designation also confirms the trimming guidance added earlier (alterations -> no numeric grade). --- vision.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/vision.py b/vision.py index 3da35a8..9b8dfdc 100644 --- a/vision.py +++ b/vision.py @@ -365,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 \ From 3510f637ba6df881b5a36edbc0656faa6a391642 Mon Sep 17 00:00:00 2001 From: Barely Removable Date: Tue, 25 Aug 2026 08:30:39 -0700 Subject: [PATCH 08/10] Stop telling the model to give up on corners/edges just for being in a holder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The framing caveat added earlier told the model to find the card's real edge inside each mis-cut crop, then immediately offered 'say cannot_assess if the close-up doesn't clearly contain it' — wrapped in alarming language about cases. On a Fleer Ultra Jordan shot in a clear toploader it took the escape hatch for corners AND edges, returning three of four categories as 'can't tell from photo' on a card whose corners are plainly visible through the plastic. That was a regression I introduced, not a limit of the photo. A toploader is transparent: corner sharpness, corner whitening, edge chipping and edge whitening all read through it, and the card's cut line is visible as a distinct boundary inside the holder's (confirmed in the pixel profile — the card's own edge shows as a clear step ~75px inside the detected holder boundary). Those categories now get judged normally, with cannot_assess reserved for genuinely hidden geometry. Surface keeps the escape hatch, because there it's correct: scratches, dust and glare on a holder sit directly over the card's surface and can't be separated from it in a photo. --- vision.py | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/vision.py b/vision.py index 9b8dfdc..2a20113 100644 --- a/vision.py +++ b/vision.py @@ -1001,18 +1001,32 @@ def grade_card(images, api_key=None, model=None, effort=None, zoom_details=True) "read resampling softness as card wear.") if framing: prompt_parts.append( - "IMPORTANT CAVEAT ON THOSE CROPS: {} Because the crop " - "rectangle is derived from that same detection, each close-up " - "is cut relative to the wrong boundary — a corner close-up may " - "well be showing the CORNER OF A CASE, SLEEVE OR HOLDER with " - "the card's own corner sitting somewhere inside the frame, or " - "out of it. Do not assume the crop's own edge is the card's " - "edge. Find the actual card edge inside each close-up first " - "and judge only that; if a given close-up doesn't clearly " - "contain the card's real corner or edge, say cannot_assess " - "for that category rather than grading the holder. Damage, " - "scuffing or whitening on a case is not damage to the " - "card.".format(framing)) + "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( From 22bb14e5d98dadfff697a599095e01cdb9a4f7ee Mon Sep 17 00:00:00 2001 From: Barely Removable Date: Tue, 25 Aug 2026 08:42:53 -0700 Subject: [PATCH 09/10] Detect the card INSIDE a holder, not the holder's outline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _detect_card_box works from 'what isn't background', so on a card shot in a toploader, sleeve or slab it finds the HOLDER — the card is never the outermost non-background thing. Every downstream crop was then cut against plastic instead of cardstock. Per-side edge detection can't disambiguate: a holder's rim is as strong a step as the card's cut, and on a card sitting off-centre in its holder the two land at different insets per side (verified on the Jordan: card's left edge at 75px, top edge at ~40px, holder rim at ~34px). What separates them is that a CARD has known proportions and a holder does not -- so this searches combinations of candidate edges and keeps whichever rectangle best matches a real card. Jordan 7.0% -> 0.3% off standard; Ohtani 5.8% -> 0.4%. Verified visually: both crops are the bare card, holder gone. Conservative by construction -- the inner box must land within 2.5% of a standard ratio AND beat the outer by 2.5 points. A correctly-detected bare card already sits near 0%, so nothing inside it can clear that bar and the refinement declines. Confirmed against six normal-photo variants (tight margin, large margin, dark background, die-cut, near-square): none refined. Two consequences handled rather than papered over: - aspect_profile deliberately keeps using the RAW box. The refinement picks the rectangle that best matches a standard ratio, so measuring that and asking 'is this a standard ratio?' is circular and would clear a genuinely trimmed card. It now refuses on holdered cards instead. - edge_wear_profile refuses on holdered cards even though the card is now located. Whitening is detected as the outermost band reading lighter than the one inside it, and a holder's inner edge sits exactly there: measured 58%/53% 'whitening' on two Jordan edges the model called clean reading the same strips. Centering does NOT refuse -- it compares border widths, which clear plastic doesn't distort (Ohtani now reads 51/49, having previously refused outright). --- cardimage.py | 187 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 183 insertions(+), 4 deletions(-) diff --git a/cardimage.py b/cardimage.py index 6b3fc36..c76f4f3 100644 --- a/cardimage.py +++ b/cardimage.py @@ -241,6 +241,141 @@ 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. @@ -306,7 +441,8 @@ def framing_warning(image_bytes): try: img = Image.open(io.BytesIO(image_bytes)) img.load() - 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) return _box_margin_reason(box, img.size) except Exception: return None @@ -501,8 +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) @@ -763,7 +921,8 @@ def centering_profile(image_bytes): try: img = Image.open(io.BytesIO(image_bytes)) img.load() - 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) card = img.crop(box) card = card.convert("RGB") @@ -933,6 +1092,13 @@ 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 @@ -949,6 +1115,18 @@ def aspect_profile(image_bytes): 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 = { @@ -1071,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] From bc6ee2cf279a4714381bab4f22f53b8497328aab Mon Sep 17 00:00:00 2001 From: Barely Removable Date: Tue, 25 Aug 2026 16:40:24 -0700 Subject: [PATCH 10/10] Add CLAUDE.md documenting project architecture and deployment Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 139 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 CLAUDE.md 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.