diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..32fe622 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,139 @@ +# Card Grader + +Self-hosted web app that estimates PSA trading card grades from photos, using +vision-capable LLMs (Claude and/or GPT) as the grading engine. Runs as a +single Docker container on the user's Unraid home server, exposed at +`hippofam.com/cards` through Nginx Proxy Manager. + +No framework: the whole backend is Python stdlib +(`http.server.ThreadingHTTPServer`) plus `sqlite3`. Optional third-party +packages are `anthropic`, `openai`, and `Pillow` (image processing only — +never required for the server to boot). + +## Architecture + +- **`app.py`** — HTTP routing/handlers. Reads Basic Auth username forwarded + by the reverse proxy for *attribution only* (who graded what), never for + access control — auth itself is enforced by Nginx Proxy Manager in front + of the container via htpasswd. Admin-only settings are gated by + `ADMIN_USERNAME` (`CARD_GRADER_ADMIN_USER` env var). Request bodies are + capped (`MAX_BODY_BYTES`); oversized/malformed bodies get a 413 and the + connection is closed rather than trusting a client-supplied length. +- **`vision.py`** — Provider abstraction over Anthropic and OpenAI vision + APIs (`_call_anthropic` / `_call_openai`, dispatched from a common + entrypoint). `MODELS` holds per-model pricing/capabilities. + `GRADING_SYSTEM` is the large system prompt encoding PSA's actual grading + rubric (centering tolerances, corner/edge/surface criteria, qualifiers — + MC/OC/PD/ST/MK, half-grades, Altered Authentic) — verified against + psacard.com, beckett.com, and cgccards.com, not guessed. Self-reported + "high" confidence is capped to "medium" when neither centering nor edge + whitening was independently measurable from the image geometry. +- **`cardimage.py`** — Pillow-based image pipeline: card boundary detection, + corner/edge/surface crop generation, centering/edge-whitening/aspect-ratio + measurement from pixels (not just left to the model's eye). Includes + holder/toploader-aware detection (`detect_card_box`): a combinatorial + search over candidate edge positions constrained to standard card aspect + ratios, so the app can locate the actual card inside a slab/toploader + rather than just detecting the holder's outline. When a holder is + detected, edge-whitening measurement is refused (the holder's own inner + edge corrupts that specific measurement) but centering still runs + (border-width geometry, unaffected by clear plastic), and aspect-ratio + measurement deliberately uses the *unrefined* box to avoid circularity + (refinement selects for standard-ratio rectangles, so measuring the + refined box's ratio would trivially always look "normal"). +- **`store.py`** — SQLite (WAL mode) persistence. `grades` and + `grade_events` tables, JSON columns for structured data, schema + migrations via idempotent `ALTER TABLE` in `store.init()`. List/detail + API responses explicitly exclude `source_images_json` to keep payloads + small. DB path overridable via `CARD_GRADER_DB_PATH`. +- **`static/`** — Vanilla JS/CSS/HTML frontend, PWA-enabled (manifest, + service worker). "PSA slab" visual theme. `__BASE__` is templated at + request time so the app works correctly when served from a sub-path + (`/cards`) behind the proxy. + +## Local development + +```bash +python3 app.py +``` + +Needs `ANTHROPIC_API_KEY` and/or `OPENAI_API_KEY` in the environment to +actually grade; the server itself has no required third-party deps. A local +`grades.db` is gitignored and is scratch/test data only — it is **not** the +production database (that lives on the server, see below). + +## Deployment + +**Target:** Unraid server, container path `/mnt/user/appdata/card-grader`, +built from `Dockerfile` / `docker-compose.yml` in this repo. Non-root +container user (`99:100` / `nobody:users`), healthcheck, log rotation, +`init: true`. Reverse-proxied by Nginx Proxy Manager at `hippofam.com/cards` +with per-user HTTP Basic Auth. + +**Current deploy mechanism (as of 2026-08-25): manual.** Changes are copied +to the server by hand (`scp`) and the container is rebuilt over SSH; the two +copies of the repo (local Mac, server) are kept in sync by committing on +both sides. There is currently no automated push-to-deploy path connected — +see below. + +**Pending: restricted git-push deploy.** A setup script +(`setup-card-grader-deploy.sh`, delivered to the user, not yet run) creates +a scoped `carddeploy` user on the Unraid box for exactly this purpose: + +- Login shell is `git-shell` — accepts git push/pull only, nothing else (no + interactive shell, no arbitrary commands, no SFTP). +- A `post-receive` hook (root-owned, mode 755 — not writable by the + `carddeploy` account, and not something `git push` can overwrite via the + protocol regardless) triggers a single root-owned deploy script via one + narrowly-scoped `sudo` rule (`NOPASSWD` for that *exact script path* + only — deliberately never raw `docker`/the `docker` group, both of which + are root-equivalent on the whole box via the daemon socket). +- The deploy script does `git checkout -f main` into the live app directory + and `docker compose up -d --build`. +- State (the bare repo, the deploy script, the sudoers source file) lives + under `/mnt/user/appdata/card-grader-deploy/` on the array — Unraid boots + from USB into RAM, so anything living only under `/` would vanish on + reboot. Setup is reinstalled idempotently via `/boot/config/go` (Unraid's + official boot-time persistence hook) so it survives reboots. + +Once the user runs that script and confirms, the local SSH config alias +`unraid-cardgrader` (in `~/.ssh/config`, already pointed at the new +`carddeploy` user and a fresh dedicated keypair +`~/.ssh/id_ed25519_cardgrader_deploy`) will be usable, and the remaining +step is adding the bare repo as a git remote here and doing a first push to +verify the pipeline end-to-end. **Do not assume this pipeline is live** — +confirm with the user before relying on it. + +A prior root SSH key/access to the whole box was explicitly revoked by the +user; the replacement above is intentionally scoped to *only* updating this +one container, not general Unraid/Docker access. + +## Conventions and constraints established for this project + +- PSA grading rules must be verified against a real source (PSA's own site, + Beckett, CGC) before being encoded into `GRADING_SYSTEM` — this rubric has + had real accuracy bugs (e.g. a self-contradictory centering tolerance, a + missing 5% front-centering leeway rule for grades 7+) found and fixed + through actual verification, not assumption. +- The vision prompt's `cannot_assess` escape hatch is deliberately narrow: + surface condition genuinely can't be separated from dust/scratches on a + toploader/slab's plastic, so it keeps the escape hatch. Corner and edge + geometry (sharpness, whitening, chipping) **does** read through clear + plastic and must be judged normally — an earlier prompt version gave the + model an escape hatch for corners/edges too, which caused false + "cannot tell from photo" results on cards that were plainly visible + through the holder. Don't reintroduce that. +- No pre-flight/"precheck" step before a paid grading call. This was tried + and explicitly rejected by the user — their workflow is screenshots that + can't be pulled out of the toploader, so a pre-check step doesn't fit and + was reverted. +- No browser automation (Playwright or otherwise) to scrape eBay listings. + Tried a plain `curl`-based fetch once (not Playwright) and got an + immediate 403; iterating on headers/fingerprinting to get past that would + be bot-detection evasion regardless of which tool performs it, and is + out of scope. This feature is paused; if revisited, the legitimate path + is a client-side bookmarklet/extension that uses the *user's own* + authenticated browser session rather than a server-side fetch. +- Never trust the reverse proxy's forwarded auth header for anything beyond + attribution (whose name to log against a grade) — access control is the + proxy's job, not the app's. diff --git a/app.py b/app.py index 793a37d..6680216 100644 --- a/app.py +++ b/app.py @@ -432,6 +432,12 @@ class Handler(BaseHTTPRequestHandler): # spending yours. Never stored — it lives in their browser and is # used for this one call. caller_key = (body.get("api_key") or "").strip() or None + # Cleared up-front, not just assigned on success. One handler + # instance serves every request on a keep-alive connection, so this + # attribute outlives the request that set it — without the reset, a + # future path that logs an event without reaching the assignment + # below would silently bill the previous request's payer. + self._last_used_own_key = False print("[grade] calling vision model={} on {} image(s){}…".format( model, len(images), " (caller key)" if caller_key else ""), flush=True) @@ -453,6 +459,7 @@ class Handler(BaseHTTPRequestHandler): "closeups": result["closeups"], "card_type": result["card_type"], "card_note": result["card_note"], + "authenticity": result["authenticity"], "edge_measurements": result["edge_measurements"], "centering_measurement": result["centering_measurement"], "aspect_measurement": result["aspect_measurement"], diff --git a/cardimage.py b/cardimage.py index af09b51..c76f4f3 100644 --- a/cardimage.py +++ b/cardimage.py @@ -241,6 +241,213 @@ def _detect_card_box(img): return box +# A holder is only a little larger than the card it holds, so the card's +# edge is always within this fraction of the detected box. Searching deeper +# would start finding the card's own printed border and inner artwork. +_INNER_SEARCH_FRACTION = 0.15 +# Candidate edges per side. More costs nothing (the combination search is +# tiny) but risks admitting weak noise peaks as candidates. +_INNER_CANDIDATES_PER_SIDE = 5 +# The inner box must land this close to a real card's proportions... +_INNER_MAX_DEVIATION = 2.5 +# ...AND beat the outer box by at least this much. Together these are what +# keeps a normal, correctly-detected card from being "refined" into its own +# artwork: a good outer box already sits near 0% deviation, so nothing +# inside it can improve by 2.5 points, and the refinement declines. +_INNER_MIN_IMPROVEMENT = 2.5 + + +def _aspect_deviation(w, h): + """Percent off the nearest standard card proportion.""" + ratio = min(w, h) / float(max(w, h)) + return min(abs(ratio - s) / s * 100.0 for s in STANDARD_ASPECT_RATIOS.values()) + + +def _edge_candidates(profile, limit): + """Strongest steps in a 1-D mean profile, nearby duplicates suppressed.""" + grad = sorted(((d, abs(profile[d + 1] - profile[d])) + for d in range(len(profile) - 1)), + key=lambda t: -t[1]) + out = [] + for d, v in grad: + if v < 8: # below this is texture, not a boundary + break + if all(abs(d - o) > 6 for o, _ in out): + out.append((d, v)) + if len(out) >= limit: + break + return out + + +def _refine_to_inner_card(img, box): + """Find the card INSIDE a holder. Returns (box, refined?). + + _detect_card_box works from "what isn't background", which on a card + photographed inside a toploader, sleeve or slab finds the HOLDER's + outline — the card is never the outermost non-background thing. Every + downstream crop is then cut against the plastic instead of the card, + which is what made a Fleer Ultra Jordan's corners and edges unreadable + despite being plainly visible through clear plastic. + + Per-side edge detection alone can't fix it: a holder's own rim is just + as strong a step as the card's cut, and on a card sitting off-centre in + its holder the two are at different insets on different sides. What + disambiguates them is that a CARD has known proportions and a holder + does not — so this searches combinations of candidate edges and keeps + the rectangle that best matches a real card, rather than trusting any + single side. On the Jordan that moved a 7.0%-off box to 0.3%-off. + + Deliberately conservative: it must both land very close to a standard + ratio and clearly beat the box it started from, or the original stands. + """ + if Image is None: + return box, False + try: + crop = img.crop(box).convert("L") + w, h = crop.size + if w < 60 or h < 60: + return box, False + px = crop.load() + + # Sample the middle half of each axis: the ends run through the + # card's rounded corners and the holder's, which blur the step. + def col_mean(x): + lo, hi = int(h * 0.25), int(h * 0.75) + return sum(px[x, y] for y in range(lo, hi)) / float(hi - lo) + + def row_mean(y): + lo, hi = int(w * 0.25), int(w * 0.75) + return sum(px[x, y] for x in range(lo, hi)) / float(hi - lo) + + nx = max(4, int(w * _INNER_SEARCH_FRACTION)) + ny = max(4, int(h * _INNER_SEARCH_FRACTION)) + per = _INNER_CANDIDATES_PER_SIDE + # Zero is always a candidate: a side may need no adjustment at all. + cl = _edge_candidates([col_mean(d) for d in range(nx)], per) + [(0, 0)] + cr = _edge_candidates([col_mean(w - 1 - d) for d in range(nx)], per) + [(0, 0)] + ct = _edge_candidates([row_mean(d) for d in range(ny)], per) + [(0, 0)] + cb = _edge_candidates([row_mean(h - 1 - d) for d in range(ny)], per) + [(0, 0)] + + outer_dev = _aspect_deviation(w, h) + best = None + for l, vl in cl: + for r, vr in cr: + for t, vt in ct: + for b, vb in cb: + iw, ih = w - l - r, h - t - b + # A card fills most of its holder; a big shrink means + # this latched onto artwork, not a cut line. + if iw < w * 0.7 or ih < h * 0.7: + continue + dev = _aspect_deviation(iw, ih) + strength = (vl + vr + vt + vb) / 4.0 + # Aspect dominates; edge strength only breaks ties + # between rectangles that fit a card equally well. + score = dev - strength * 0.05 + if best is None or score < best[0]: + best = (score, dev, (l, t, r, b)) + if best is None: + return box, False + + _, dev, (l, t, r, b) = best + if dev > _INNER_MAX_DEVIATION: + return box, False + if outer_dev - dev < _INNER_MIN_IMPROVEMENT: + return box, False + if not (l or t or r or b): + return box, False + return (box[0] + l, box[1] + t, box[2] - r, box[3] - b), True + except Exception: + return box, False + + +def detect_card_box(img): + """(box, refined?) — the card's own outline where that can be found. + + `refined` is True when the box had to be pulled in from a surrounding + holder. Callers that measure the card's SHAPE need to know: the + refinement picks the rectangle closest to a standard card ratio, so + asking it afterwards whether the card has a standard ratio is circular + and would always answer yes. See aspect_profile. + """ + box = _detect_card_box(img) + if not box: + return None, False + return _refine_to_inner_card(img, box) + + +def _box_margin_reason(box, img_size): + """None if the detected box leaves a plausible margin on enough sides + to trust; otherwise the reason it doesn't. + + Exists because of a real failure: a card photographed still inside a + black display case, where the case filled the whole frame with no + visible background anywhere. _detect_card_box's background-from-corners + approach has nothing to key off in that situation, and it isn't obvious + from the box alone — it returned (0, 23, 985, 1327) on a 986x1346 + photo, which LOOKS like a plausible tight crop, not an obvious failure + like the too-small-area case already guarded against above. But at + essentially zero margin, every downstream measurement's outer/inner + sampling bands — a few percent of the card's own short dimension, by + design, since real wear lives in the outermost sliver — land entirely + inside whatever surrounds the true card, never reaching it. On that + photo they read the case's own embossed texture as "whitening": no + single edge disagreed with the others (the case is uniformly dark on + all four sides), so the per-edge consistency checks elsewhere in this + file never saw a reason to object. + + Two real situations produce a near-zero margin, and there's no reliable + way to tell them apart from pixels alone: a card genuinely photographed + edge-to-edge with nothing but card in frame, or something surrounding + the card being read as part of it. Refusing both is the safer + direction — a full-bleed card that loses its pixel measurement still + gets graded from the model's own eye, same as any other refusal here; + a case silently read as the card produces a confident, wrong number + that drags the whole grade down with it. + """ + w, h = img_size + left, top, right, bottom = box + margins = (left / w, top / h, (w - right) / w, (h - bottom) / h) + # Under 2.5% of that dimension, on at least two of the four sides. + if sum(1 for m in margins if m < 0.025) >= 2: + return ("this photo's card fills nearly the entire frame with no " + "usable margin around it, so the measurement bands — a few " + "percent of the card's own edge, where real wear actually " + "lives — would land on whatever's in that margin rather " + "than the card. Either the card was shot genuinely " + "edge-to-edge, or something around it (a case, a slab, a " + "holder) filled the frame instead. Reshoot with visible " + "background/mat around the card on all sides for a " + "measurement that means anything.") + return None + + +def framing_warning(image_bytes): + """The margin problem described in _box_margin_reason, or None. + + Public because the CROPS have the same exposure to it as the pixel + measurements do, and worse consequences. When box detection can't find + the card's real boundary, every crop is cut relative to the wrong + rectangle — a "TOP-LEFT CORNER" close-up of a cased card shows the + CASE's corner bracket, with the card's actual corner off to one side. + The measurements at least refuse and say why; the crops keep being + produced and keep being captioned authoritatively, and the grading + prompt tells the model to judge corners/edges/surface *from* them. So + the caller needs to be able to warn about the framing rather than + silently pass off plastic as cardstock. + """ + if Image is None: + return None + try: + img = Image.open(io.BytesIO(image_bytes)) + img.load() + box, _refined = detect_card_box(img) + box = box or (0, 0, img.width, img.height) + return _box_margin_reason(box, img.size) + except Exception: + return None + + def _surface_map(piece): """A band-pass view that isolates surface texture from the artwork. @@ -430,7 +637,30 @@ def edge_wear_profile(image_bytes): img.load() if img.mode not in ("RGB", "L"): img = img.convert("RGB") - box = _detect_card_box(img) or (0, 0, img.width, img.height) + box, refined = detect_card_box(img) + box = box or (0, 0, img.width, img.height) + margin_reason = _box_margin_reason(box, img.size) + # A holder defeats this particular measurement even once the card + # itself has been located. Whitening is detected as the outermost + # band reading lighter and less saturated than the band just inside + # it — and a toploader's inner edge sits exactly there, adding its + # own reflection to the outer band on a perfectly clean card. + # Measured on a Fleer Ultra Jordan in a toploader: 58% and 53% + # "whitening" on two edges the model, reading the same strips by + # eye, called clean. + # Centering deliberately does NOT refuse here — it compares border + # WIDTHS, which is geometry that clear plastic doesn't distort, so + # it stays measurable through a holder. + if refined and not margin_reason: + margin_reason = ( + "the card is inside a holder, sleeve or slab. Its cut edges " + "were located, but a whitening measurement reads the very " + "outermost sliver of the card — where the holder's own inner " + "edge and its reflections sit — so the number would describe " + "the plastic as much as the card. Judge the edges from the " + "strip images instead: cut lines and whitening are perfectly " + "visible through clear plastic even though they can't be " + "measured through it") card = img.crop(box).convert("RGB") short = min(card.size) @@ -479,22 +709,75 @@ def edge_wear_profile(image_bytes): ss = sorted(c[1] for c in cols) edge_medians[name] = (ls[len(ls) // 2], ss[len(ss) // 2]) + # Two different failure shapes live here, and they need different + # responses. Both start from the same question — do all four edges' + # OWN median readings (one number per side, regardless of how many + # columns that side happened to contribute — see below for why that + # matters) describe one consistent border material? + # + # Deliberately NOT using the quantity-weighted pooled percentiles + # for this check, only the four per-edge medians. A portrait card's + # left/right edges are the long dimension and contribute far more + # columns than top/bottom — found on a real card photographed still + # inside a black display case, where left/right (case material, + # ~1135 columns each) outnumbered top/bottom (~90 columns each) by + # 12-to-1. Pooled by column, the wrong material dominates both the + # 25th and 75th percentile, so the refractor/iqr check below stays + # quiet even though two sides are reading something else entirely. + # Per-edge medians weight all four sides equally regardless of + # their length, which is what actually catches it. outliers = {} - for name, (l_med, s_med) in edge_medians.items(): - others = [v for n, v in edge_medians.items() if n != name] - if len(others) < 2: - continue - other_l = sorted(v[0] for v in others)[len(others) // 2] - other_s = sorted(v[1] for v in others)[len(others) // 2] - if (l_med - other_l) >= 45 and (other_s - s_med) >= 35: - outliers[name] = ( - "this edge's finish reads as a different material from " - "the card's other edges (much brighter and less " - "saturated) — likely a clear acetate window, a die-cut " - "insert, or a foil accent on this side only, not " - "whitening. A paper-showing-through measurement doesn't " - "apply to a material that was never opaque to begin with." - ) + whole_card_reason = None + if len(edge_medians) == 4: + names = list(edge_medians) + lumas_by_edge = {n: edge_medians[n][0] for n in names} + overall_spread = max(lumas_by_edge.values()) - min(lumas_by_edge.values()) + # 60 is comfortably above ordinary lighting/exposure variation + # across a card's four sides (seen in practice: 10-30) and well + # below a genuine different-material gap (150+ for a die-cut + # window or a case's plastic against real cardstock). No + # saturation requirement here, unlike the old version of this + # check: a black case against a white or cream border is a huge + # luma gap with barely any saturation signal at all, since + # neither material has real colour to lose. + if overall_spread >= 60: + # Which single side, if any, explains the whole gap? Try + # excluding each one in turn and keep whichever exclusion + # leaves the tightest remaining trio. + best_excl, best_spread = None, None + for excl in names: + rest = [lumas_by_edge[n] for n in names if n != excl] + spread = max(rest) - min(rest) + if best_spread is None or spread < best_spread: + best_excl, best_spread = excl, spread + if best_spread < 45: + # A single outlier explains it — die-cut window, foil + # accent strip, one side only. Exclude just that side. + outliers[best_excl] = ( + "this edge's finish reads as a different material " + "from the card's other edges — likely a clear " + "acetate window, a die-cut insert, or a foil accent " + "on this side only, not whitening. A " + "paper-showing-through measurement doesn't apply to " + "a material that was never opaque to begin with." + ) + else: + # No single exclusion brings the rest into agreement — + # the readings split into two genuinely different + # groups (e.g. two sides read one material, two read + # another), so there's no way to tell algorithmically + # which pair is the card's real border. Refuse outright + # rather than guess. + whole_card_reason = ( + "the four edges don't read as one consistent border " + "material — some sides measure roughly {:.0f} luma, " + "others roughly {:.0f}, with nothing in between. " + "This usually means the card is still in a case, " + "slab, or protective holder in the photo, so what " + "got measured on some sides is the holder, not the " + "card. Take the card out of anything it's in and " + "reshoot for a measurement that means anything." + ).format(min(lumas_by_edge.values()), max(lumas_by_edge.values())) # Baseline from the non-outlier edges pooled, not each edge against # itself. Whitening only ever raises luma and lowers saturation, so @@ -530,17 +813,20 @@ def edge_wear_profile(image_bytes): # answer is the right outcome there; a wrong number is worse than no # number, because it drags the whole grade down with it. iqr = lumas[int(len(lumas) * 0.75)] - lumas[int(len(lumas) * 0.25)] - reason = None - if base_l >= 205 and base_s <= 45: + reason = margin_reason or whole_card_reason + if reason is None and base_l >= 205 and base_s <= 45: reason = ("the card's border is white, silver or foil, where paper " "showing through looks the same as the border itself") - elif iqr >= 70: + elif reason is None and iqr >= 70: reason = ("the border's brightness varies too much across the card " "— typical of a refractor or prismatic finish — for a " "whitening measurement to mean anything") + # A whole-card refusal makes every individual edge score meaningless + # too — there's no reliable baseline left to score any of them + # against, not just the sides that triggered it. edges = { - name: (None if name in outliers + name: (None if (reason is not None or name in outliers) else (_score_edge(cols, base_l, base_s) if cols else None)) for name, cols in collected.items() } @@ -635,12 +921,17 @@ def centering_profile(image_bytes): try: img = Image.open(io.BytesIO(image_bytes)) img.load() - card = img.crop(_detect_card_box(img) or (0, 0, img.width, img.height)) + box, _refined = detect_card_box(img) + box = box or (0, 0, img.width, img.height) + margin_reason = _box_margin_reason(box, img.size) + card = img.crop(box) card = card.convert("RGB") w, h = card.size if w < 60 or h < 60: return None px = card.load() + if margin_reason: + return {"reliable": False, "reason": margin_reason} # The border colour, sampled just inside the cut at the midpoint of # each side — far from corners and from any design element. A small @@ -667,25 +958,57 @@ def centering_profile(image_bytes): } # All four sides must plausibly be the SAME border before a ratio of - # their widths means anything. A side whose colour sits far from the - # median of the other three is a different material — a clear die-cut - # window showing the background, a foil accent strip — and one such - # side invalidates the whole geometric premise, not just its own - # number: the walk inward on every side keys off one shared - # border_rgb. Refuse with the reason rather than return a ratio. - for side, colour in side_samples.items(): - others = [v for s, v in side_samples.items() if s != side] - med = tuple(sorted(o[i] for o in others)[len(others) // 2] - for i in range(3)) - if sum(abs(colour[i] - med[i]) for i in range(3)) > 150: + # their widths means anything. Checking each side against the median + # of the other three, one at a time, and stopping at the first hit + # was tried first — and silently mishandled the case that matters + # most: a card still in a black case, where left/right sample the + # case (dark) and top/bottom sample the real card border (bright). + # That's a 2-vs-2 split, not one outlier — comparing "left" against + # the median of {right, top, bottom} lands on a bright value (2 of + # those 3 are bright), so left alone trips the check, the loop + # returns immediately, and "right" — equally case, equally wrong — + # is never even examined, so the message blames one side for a + # problem that's actually on two. + # + # This tries excluding each single side in turn and keeps whichever + # exclusion leaves the other three closest together, which correctly + # tells a real single-outlier case (die-cut window, foil strip — + # excluding it brings the rest into tight agreement) apart from a + # split where no single exclusion works, because two sides are wrong. + names = list(side_samples) + best_excl, best_spread = None, None + for excl in names: + rest = [side_samples[n] for n in names if n != excl] + spread = max( + max(v[i] for v in rest) - min(v[i] for v in rest) for i in range(3)) + if best_spread is None or spread < best_spread: + best_excl, best_spread = excl, spread + + overall_spread = max( + max(v[i] for v in side_samples.values()) - min(v[i] for v in side_samples.values()) + for i in range(3)) + if overall_spread > 90: + if best_spread is not None and best_spread <= 40: return { "reliable": False, "reason": ("the {} side's border reads as a completely " "different colour/material from the other " "sides — typical of a die-cut or clear-window " "card, where a border-width ratio doesn't " - "describe centering at all").format(side), + "describe centering at all").format(best_excl), } + return { + "reliable": False, + "reason": ("the four sides don't read as one consistent " + "border — they split into at least two different " + "colours/materials with no single side " + "explaining it. This usually means the card is " + "still in a case, slab, or protective holder in " + "the photo, so some sides measured the holder " + "instead of the card. Take it out and reshoot " + "for a centering measurement that means " + "anything."), + } samples = list(side_samples.values()) border_rgb = tuple(sorted(c[i] for c in samples)[len(samples) // 2] @@ -769,12 +1092,41 @@ def aspect_profile(image_bytes): try: img = Image.open(io.BytesIO(image_bytes)) img.load() + # Deliberately the RAW box, not the refined one every other caller + # uses. _refine_to_inner_card picks whichever candidate rectangle + # best matches a standard card ratio — so measuring that rectangle's + # ratio and asking "is this a standard card ratio?" is circular, and + # would answer yes on a genuinely trimmed card. Trimming detection + # only means anything against a boundary found without reference to + # the answer. box = _detect_card_box(img) if not box: return None bw, bh = box[2] - box[0], box[3] - box[1] if bw < 40 or bh < 40: return None + # This measurement IS the detected box, so a box that didn't find + # the card's real boundary doesn't produce an approximate ratio — + # it produces the ratio of something else entirely. On a cased card + # it reported 5.8% off standard, which is the CASE's proportions; + # the UI would then show a trimming hint about a card it never + # measured. Same guard as edge/centering, and more directly + # load-bearing here than for either of those. + margin_reason = _box_margin_reason(box, img.size) + if margin_reason: + return {"reliable": False, "reason": margin_reason} + # A card sitting inside a holder is the other way this box can be + # the wrong rectangle — the margin check won't catch it when the + # holder itself is well framed. Refuse rather than report the + # holder's proportions as the card's. + if _refine_to_inner_card(img, box)[1]: + return { + "reliable": False, + "reason": ("the card appears to be inside a holder, sleeve or " + "slab, so the outline measured here is the " + "holder's rather than the card's. Its proportions " + "say nothing about whether the card was trimmed"), + } ratio = min(bw, bh) / float(max(bw, bh)) against_standards = { @@ -787,6 +1139,7 @@ def aspect_profile(image_bytes): best_name = min(against_standards, key=lambda n: against_standards[n]["deviation_percent"]) return { + "reliable": True, "measured_ratio": round(ratio, 4), "width_px": bw, "height_px": bh, @@ -896,7 +1249,8 @@ def detail_crops(image_bytes, filename="card.jpg", corners=True, edges=True, if max(img.size) < MIN_SOURCE_PX: return [] - box = _detect_card_box(img) or (0, 0, img.width, img.height) + box, _refined = detect_card_box(img) + box = box or (0, 0, img.width, img.height) card = img.crop(box) w, h = card.width, card.height stem = filename.rsplit(".", 1)[0] diff --git a/static/app.js b/static/app.js index 53010db..08360fb 100644 --- a/static/app.js +++ b/static/app.js @@ -157,7 +157,8 @@ function renderGradeBlock(g, opts = {}) { // would just be clutter, not information. Shown against its best-matching // standard; which standard is actually relevant is a judgment call the // model made with the photo in hand, not something this line re-derives. - const am = g.aspect_measurement || null; + const am = (g.aspect_measurement && g.aspect_measurement.reliable !== false) + ? g.aspect_measurement : null; const bestDev = am && am.against_standards && am.best_match ? am.against_standards[am.best_match].deviation_percent : null; const aspectLine = (bestDev !== null && bestDev >= 3) @@ -182,7 +183,16 @@ function renderGradeBlock(g, opts = {}) { `; }).join(''); + const auth = g.authenticity || null; + const authBanner = (auth && auth.flag !== 'none') ? ` +
` : ''; + return ` + ${authBanner}