"""Estimate a trading card's PSA grade from photographs, using a vision model. Supports Anthropic (Claude) and OpenAI as interchangeable providers — pick one per grade via MODELS below; the prompt, schema and cardimage.py measurements are identical either way, only _call_anthropic/_call_openai differ. This is the one part of the app that costs money per use, and the one part that can be confidently wrong — a model eyeballing a photo can miss real wear or invent damage that isn't there. The contract here is deliberately narrow: * it returns an *estimate*, with a range and a confidence, never a verdict * every category can honestly say "cannot_assess" rather than guess * two of PSA's four categories (centering, edge whitening) are measured directly from the pixels in cardimage.py and handed to the model as numbers, rather than asked for by eye — see that module for why Works on any trading card: Pokemon, sports, Magic, whatever PSA grades. """ import base64 import json import os import time import cardimage try: import anthropic except ImportError: # keeps the rest of the app importable without the SDK anthropic = None try: import openai except ImportError: openai = None # Per-model capabilities. These differ in ways that are 400 errors, not # preferences, so the request is built from this table rather than assuming a # single shape: # * `effort` is rejected outright by Haiku 4.5 — on that model "cheapest" # means omitting `thinking`, which turns thinking off entirely. # * `fallbacks` (server-side refusal recovery) only applies to the Opus/Fable # tier; sending it elsewhere isn't supported. # * image token cost differs because Haiku caps images at 1568px on the long # edge while Sonnet 5 and Opus 5 accept 2576px. Oversized images are scaled # down server-side, so there's nothing to do client-side either way. MODELS = { "claude-haiku-4-5": { "label": "Haiku 4.5 — cheapest", "provider": "anthropic", "effort": False, # sending output_config.effort is a 400 "adaptive_thinking": False, "fallbacks": False, "in_per_mtok": 1.00, "out_per_mtok": 5.00, "approx_image_tokens": 1600, }, "claude-sonnet-5": { "label": "Sonnet 5 — balanced", "provider": "anthropic", "effort": True, "adaptive_thinking": True, "fallbacks": False, # Introductory pricing, which EXPIRES — see `priced_from` below. # Hardcoding the intro rate alone would have quietly under-reported # every grade by a third from 2026-09-01 onward. "in_per_mtok": 2.00, "out_per_mtok": 10.00, "priced_from": [("2026-09-01", 3.00, 15.00)], "approx_image_tokens": 4800, }, "claude-opus-5": { "label": "Opus 5 — most accurate", "provider": "anthropic", "effort": True, "adaptive_thinking": True, "fallbacks": True, "in_per_mtok": 5.00, "out_per_mtok": 25.00, "approx_image_tokens": 4800, }, "gpt-5.6-sol": { "label": "GPT-5.6 Sol (OpenAI) — balanced", "provider": "openai", # No effort/thinking control wired up for this provider yet — every # call runs at whatever this model's default reasoning depth is. "effort": False, "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, }, } # Grading rewards the extra reasoning a thinking-capable model does — telling # a print line from a crease, a reflection from real whitening — so this # defaults to Sonnet regardless of what a cost-conscious default might # otherwise pick. Tested directly against Haiku on the same cards: Haiku # inverted a PSA centering-tolerance comparison and missed a grade by three # levels on a card Sonnet read correctly. The per-grade cost difference is a # few cents; a wrong grade estimate costs more than that. DEFAULT_MODEL = "claude-sonnet-5" DEFAULT_EFFORT = "low" # On thinking-capable models this budget covers thinking *and* the JSON, since # max_tokens caps their sum. On Haiku there's no thinking, so it's just the JSON. MAX_TOKENS = 8000 SUPPORTED_MEDIA = { "png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "gif": "image/gif", "webp": "image/webp", } class VisionError(Exception): pass def available(): """Is the feature usable right now, on at least one provider?""" return ((anthropic is not None and bool(_env_api_key("anthropic"))) or (openai is not None and bool(_env_api_key("openai")))) def _env_api_key(provider): var = "ANTHROPIC_API_KEY" if provider == "anthropic" else "OPENAI_API_KEY" return os.environ.get(var, "").strip() def media_type(filename, image_bytes=None): """The MIME type to declare for an image. Prefers what the bytes actually are over what the filename claims — filenames arriving from a phone are frequently useless (no extension, a content:// URI, a .HEIC that is really being converted upstream), and refusing a readable image because of its name is the wrong failure. """ if image_bytes: sniffed = cardimage.sniff_format(image_bytes) if sniffed in cardimage.DIRECTLY_SUPPORTED: return "image/{}".format(sniffed) ext = (filename or "").rsplit(".", 1)[-1].lower() return SUPPORTED_MEDIA.get(ext) def _build_content(images, labels, image_block): """Shared across providers: captions + encoded images, in request order. `image_block(mime, b64)` returns the provider-specific dict for one image — the two SDKs disagree on that shape, nothing else here differs. """ content = [] for index, (image_bytes, filename) in enumerate(images): mime = media_type(filename, image_bytes) if not mime: raise VisionError( "Unsupported image type '{}'. Use PNG, JPEG, GIF or WebP.".format(filename) ) # A caption immediately before its image is far more reliable than # describing the running order once up front — with nine images in a # grading request, positional bookkeeping is exactly what a model # loses track of, and mislabelling which edge is worn is worse than # not reporting it. if labels and index < len(labels) and labels[index]: content.append({"type": "text", "text": labels[index]}) encoded = base64.standard_b64encode(image_bytes).decode("utf-8") content.append(image_block(mime, encoded)) return content def _call_vision(images, system, schema, prompt, api_key=None, model=None, effort=None, max_tokens=None, labels=None): """Dispatch to the right provider's implementation. Raises VisionError with a human-readable message on any failure — callers surface it in the UI rather than half-committing anything. Everything provider-specific (request shape, auth, refusal/truncation handling, usage-field names) lives in _call_anthropic / _call_openai below; this only picks which one runs. """ if not images: raise VisionError("No images to analyze.") model = model if model in MODELS else DEFAULT_MODEL caps = MODELS[model] provider = caps.get("provider", "anthropic") key = (api_key or _env_api_key(provider)) or None if not key: raise VisionError( "No {} API key set. Add one in Settings, or export {} before " "starting the app.".format( "Anthropic" if provider == "anthropic" else "OpenAI", "ANTHROPIC_API_KEY" if provider == "anthropic" else "OPENAI_API_KEY")) if provider == "openai": return _call_openai(images, system, schema, prompt, key, model, max_tokens or MAX_TOKENS, labels) return _call_anthropic(images, system, schema, prompt, key, model, caps, effort or DEFAULT_EFFORT, max_tokens or MAX_TOKENS, labels) def _call_anthropic(images, system, schema, prompt, key, model, caps, effort, max_tokens, labels): if anthropic is None: raise VisionError( "The anthropic package isn't installed. Run: " "python3 -m pip install --user anthropic" ) client = anthropic.Anthropic(api_key=key) content = _build_content(images, labels, lambda mime, b64: { "type": "image", "source": {"type": "base64", "media_type": mime, "data": b64}}) content.append({"type": "text", "text": prompt}) params = { "model": model, "max_tokens": max_tokens, "system": system, "output_config": {"format": {"type": "json_schema", "schema": schema}}, "messages": [{"role": "user", "content": content}], } if caps["effort"]: # Effort is the right lever for these models. Deliberately not # disabling thinking here: the cheap path is Haiku (which has no # thinking at all), and a thinking-disabled Opus gives up the exact # capability you'd be paying for. params["output_config"]["effort"] = effort try: if caps["fallbacks"]: # Safety classifiers can decline a request outright; a fallback # re-runs it on another model server-side instead of failing. response = client.beta.messages.create( betas=["server-side-fallback-2026-07-01"], fallbacks="default", **params, ) else: response = client.messages.create(**params) except anthropic.AuthenticationError: raise VisionError("Anthropic rejected the API key. Check it in Settings.") except anthropic.PermissionDeniedError: raise VisionError("That API key doesn't have access to {}.".format(model)) except anthropic.RateLimitError: raise VisionError("Anthropic is rate-limiting you. Wait a moment and retry.") except anthropic.BadRequestError as exc: raise VisionError("Anthropic rejected the request: {}".format(exc)) except anthropic.APIConnectionError: raise VisionError("Couldn't reach Anthropic. Check your connection.") except anthropic.APIStatusError as exc: raise VisionError("Anthropic error {}: {}".format(exc.status_code, exc)) # A refusal returns HTTP 200 with empty/partial content — check before reading. if response.stop_reason == "refusal": raise VisionError( "Claude declined to analyze this image. Try a different photo." ) if response.stop_reason == "max_tokens": raise VisionError( "The response was cut off. Try fewer/simpler images at a time." ) text = next((b.text for b in response.content if b.type == "text"), None) if not text: raise VisionError("Claude returned no readable result for this image.") try: parsed = json.loads(text) except ValueError: raise VisionError("Claude's response wasn't valid JSON.") usage = response.usage return parsed, { "input_tokens": getattr(usage, "input_tokens", None), "output_tokens": getattr(usage, "output_tokens", None), "model": response.model, } def _call_openai(images, system, schema, prompt, key, model, max_tokens, labels): if openai is None: raise VisionError( "The openai package isn't installed. Run: " "python3 -m pip install --user openai" ) client = openai.OpenAI(api_key=key) content = _build_content(images, labels, lambda mime, b64: { "type": "image_url", "image_url": {"url": "data:{};base64,{}".format(mime, b64)}}) content.append({"type": "text", "text": prompt}) try: response = client.chat.completions.create( model=model, messages=[{"role": "system", "content": system}, {"role": "user", "content": content}], response_format={ "type": "json_schema", "json_schema": {"name": "psa_grade_estimate", "strict": True, "schema": schema}, }, # Newer reasoning-capable models reject the older `max_tokens` # name outright; this is the one Chat Completions accepts now. max_completion_tokens=max_tokens, ) except openai.AuthenticationError: raise VisionError("OpenAI rejected the API key. Check it in Settings.") except openai.PermissionDeniedError: raise VisionError("That API key doesn't have access to {}.".format(model)) except openai.RateLimitError: raise VisionError("OpenAI is rate-limiting you. Wait a moment and retry.") except openai.BadRequestError as exc: raise VisionError("OpenAI rejected the request: {}".format(exc)) except openai.APIConnectionError: raise VisionError("Couldn't reach OpenAI. Check your connection.") except openai.APIStatusError as exc: raise VisionError("OpenAI error {}: {}".format(exc.status_code, exc)) choice = response.choices[0] # content_filter is OpenAI's refusal equivalent; length is a truncation, # same distinction Anthropic's stop_reason makes, different vocabulary. if choice.finish_reason == "content_filter": raise VisionError("OpenAI declined to analyze this image. Try a different photo.") if choice.finish_reason == "length": raise VisionError("The response was cut off. Try fewer/simpler images at a time.") text = choice.message.content if choice.message else None if not text: raise VisionError("OpenAI returned no readable result for this image.") try: parsed = json.loads(text) except ValueError: raise VisionError("OpenAI's response wasn't valid JSON.") usage = response.usage return parsed, { "input_tokens": getattr(usage, "prompt_tokens", None), "output_tokens": getattr(usage, "completion_tokens", None), "model": response.model, } GRADING_SYSTEM = """You estimate what PSA grade a trading card would likely \ receive, from photograph(s) of it. The card may be Pokemon, another trading \ card game, or a sports card — PSA grades all of them on the same four \ criteria. Be honest about the ceiling on this. Graders work with the physical card under \ raking light and magnification; you have a photo. Surface scratches, print \ lines, dimples, and light edge whitening are frequently invisible in a normal \ photo — especially a seller's listing photo, which is often deliberately lit \ to hide them. Centering is the one thing a straight-on photo shows reliably. \ So: assess what you can actually see, say plainly what you cannot, and let \ the estimate range reflect that uncertainty rather than projecting false \ precision. 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 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 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 \ computed from the pixels and are accurate to within a couple of percentage \ points, which is finer than this can be eyeballed. Treat a measured ratio \ as authoritative over your visual impression unless the photo is clearly \ taken at an angle, which stretches one border and invalidates the geometry. Where no measurement is given, judge it from a straight-on shot only. An \ angled photo distorts borders in exactly the way that mimics or hides a \ centering problem, so say you cannot assess it rather than guessing. Note that centering is a manufacturing trait, not damage: a badly centred \ card can still be pristine, and PSA may grade it strongly with an OC \ (off-centre) qualifier rather than a low number. DIE-CUT and clear-window cards (SPx, E-X Century, jersey- or \ shape-die-cuts) have no uniform rectangular border, so border-width ratios \ mostly do not apply to them. Judge centering there as the printed design's \ registration to the die-cut shape and the card's physical edges — that is \ what PSA keys off for these issues — and do not reconstruct a border ratio \ by eye on a card that has no border. If a MEASURED CENTERING block was \ supplied anyway on such a card, distrust it outright: the measurement \ assumes a uniform printed border this card does not have. - CORNERS: look for whitening, fraying, softness, or blunting at each of the \ four corners. Sharp corners on all four is 9-10 territory; slight whitening \ visible under magnification but not to the eye is 8-9; obvious whitening or \ rounding drops it further. Assess all four separately and let the worst one \ drive the category — graders do not average corners. When magnified corner close-ups are provided, judge this category from \ them rather than from the full-card photo. They are digitally cropped and \ UPSCALED from that same photo, which means they add no information the \ original didn't contain: they only make existing detail easier to see. So \ treat softness, blur, or smeared edges that look like resampling artefacts \ as artefacts, NOT as card damage. Real corner wear looks like fibrous \ white paper showing through a coloured border, or a visibly blunted or \ bent tip — not a uniformly soft edge. If a close-up is simply too blurry \ to tell the difference, say cannot_assess. A corner that looks out-of-square — not rounded or frayed, but genuinely \ cut or angled differently from the other three — needs a different read than \ ordinary wear, and three distinct things can produce it: PHOTO ANGLE, rule this out first. A shot taken even slightly off-axis \ compresses and skews the far corners relative to the near ones, exactly \ mimicking a bad cut. If the card's outline looks like a parallelogram in a \ way that follows the perspective of the shot — corners nearer the camera \ reading "wider" — that is the photo, not the card. Do not report it as a \ defect. A genuine MISCUT is a factory cutting error: the border runs visibly \ wider on one side than centering alone would explain, part of an adjacent \ card's image bleeds in at one edge, or a border is entirely missing on one \ side. PSA's MC qualifier is REQUIRED whenever this is present — it is never \ optional and a miscut card never gets a plain numeric grade, only one with \ MC attached. Say so explicitly when you see this pattern (it has to affect \ the design-to-edge relationship generally, not just look like one corner is \ off), since a miscut can coexist with an otherwise pristine card and \ qualifier cards are conventionally valued around two grade-points below an \ unqualified card at the same number — worth naming even though it isn't \ reflected in the numeric estimate itself. TRIMMING is more serious than either of the above and much rarer: it is \ post-manufacture alteration, done to fake sharper corners or edges than the \ card actually has, and PSA refuses ANY numeric grade once it's identified — \ not a low grade, no grade at all. The tells are specific, so only raise this \ when you actually see them, not from a single corner looking slightly odd: \ corners that appear to curl or point UPWARD off the card's plane (PSA's own \ term for this is a "bat-ear" cut), edges or corners that are glossy, \ unnaturally sharp, or hooked compared to the card's own factory cut \ elsewhere, or a cut that looks wavy/rippled rather than flat. A single \ photo cannot confirm trimming the way calipers and UV light can, so treat it \ as a possibility worth flagging in the note for the owner to check by hand \ — phrase it as "worth checking for trimming" rather than a verdict, but do \ name the specific visual tell that prompted it. Absent any of the above, a single corner that's mildly not-square while \ the rest of the card reads as a clean rectangle is ordinary manufacturing cut \ tolerance — common enough, especially pre-1980s — and not worth flagging on \ its own. Reserve this whole paragraph for when you can actually see one of \ the three specific patterns above, not as a reason to hedge on an otherwise \ normal-looking corner. - EDGES: look for whitening, nicks, chipping, or roughness along the four \ edges. Assess top, right, bottom and left separately and let the worst one \ drive the category — graders do not average edges. Report whitening you can actually see. A visible white or light band along \ a cut edge is real wear and belongs in this category even if it is thin, \ even if it runs along only part of one side, and even if the rest of the \ card looks clean — a single whitened edge is routinely the difference \ between a 9 and a 7. Do not talk yourself out of something visible on the \ grounds that it "might be lighting": if a light band follows the cut line \ consistently, call it. Say cannot_assess only when you genuinely cannot \ see the edge, not when you can see it and are unsure how bad it is. Context that changes what counts as normal, not whether to report it: \ dark-bordered cards (1971 Topps, many modern chrome/prizm parallels) show \ the same amount of wear far more obviously than light-bordered ones, and \ white-bordered cards can hide it almost entirely — so on a white border, \ look for a change in texture or a frayed cut line rather than a colour \ change. When magnified edge strips are provided, judge this category from them. - SURFACE: look for scratches, print lines, indentations, creases, staining, \ loss of gloss, or foil/holo scratching. When SURFACE INSPECTION images are provided, judge this category from them, \ and do not answer cannot_assess without saying which specific thing you \ could not check. Each of those images is one region of the card shown \ TWICE: the untouched crop on the left, and on the right a processed version \ that cancels the artwork and leaves only fine surface texture. A scratch \ that is invisible on the left is often obvious on the right, which is the \ whole point of showing both. Read them together. The right panel tells you WHERE something is; the left \ panel tells you WHAT it is. Only call something surface damage when it makes \ sense in both: a real scratch is a thin line that runs across artwork and \ text alike, ignoring the picture's own content, and it stays in the same \ place in both panels. Things that light up in the right panel and are NOT damage — do not report \ these: the outline of every letter and number (text edges always glow); the \ border between two areas of artwork; the regular dot or rosette pattern of \ the printing itself; the deliberate texture on holo, foil, etched and \ reverse-holo cards; and blocky square patterns from image compression. If \ the right panel is uniformly busy rather than showing distinct lines, that \ is print texture, not damage. That uniformly-busy, texture-not-damage case is a "none" finding for that \ quadrant — you looked, you could tell it apart from real damage, so say so \ plainly. That is a different thing from cannot_assess, which is for when the \ PHOTO fails you (real glare washing out a region, motion blur, a quadrant too \ dark to make out either panel) rather than for when the panels are readable \ but nothing damage-like stands out. Treat every SURFACE INSPECTION pair \ you're given as sufficient, by default, to reach a real severity for that \ quadrant — hold cannot_assess to the same bar you'd apply to a corner or edge \ close-up that is genuinely too blurry to read, not to one that's merely busy. Corroborate before calling something damage, the same direction as the \ crease-vs-print-line rule below: a mark that shows up in the right (processed) \ panel but that you cannot also point to, even faintly, in the left \ (untouched) panel is more often a compression artefact or noise from the \ processing than a real scratch. The right panel finds candidates; the left \ panel confirms them. Don't report a right-panel-only mark as more than \ "minor", and don't report it at all if you can't describe where it sits in \ the untouched crop too. PRINT LINES are the important exception to that, and they are easy to \ dismiss as holo texture when they are not. A print line is a STRAIGHT band \ running parallel to one edge, usually spanning most or all of the card's \ width or height, of even thickness along its whole length, and it cuts \ straight across artwork, text and background alike without regard for any \ of them. Holo and refractor patterns radiate, swirl, or scatter; a print \ line does not — it is mechanical, made by a roller, and looks it. On a \ foil, refractor or chrome card it often shows as a band where the shimmer \ is interrupted or duller than the rest. Look specifically for one across \ each third of the card, name where it runs, and report it — PSA treats it \ as a print defect (the PD qualifier) and a pronounced one caps the grade \ regardless of how clean everything else is. Genuinely unassessable cases still exist — heavy glare hiding a whole \ region, a photo out of focus, or a holo pattern so strong it would mask a \ scratch. Say so specifically when that happens. But a normal, in-focus \ photo with these inspection images is enough to reach a real answer, so \ reaching for cannot_assess by default is not the honest choice here — it is \ just the uninformative one. On chrome and refractor stock, expect fine scratching; it is the norm rather \ than the exception, and its absence is what is notable. HOW PSA COMBINES THE FOUR The grade is capped by the WORST attribute, not averaged across them. Three \ pristine categories and one clear problem is a card graded on the problem. \ Work out the ceiling each category allows and take the lowest. What each grade tolerates, in practice: 10 GEM-MT four sharp corners, full original gloss, no staining, sharp \ focus. One slight print imperfection is allowed. 9 MINT essentially a 10 with exactly ONE minor flaw — a slight wax \ stain on the back, a minor print imperfection, or slightly \ off-white borders. 8 NM-MT looks 9 at a glance; on close inspection the slightest fraying \ at one or two corners, a minor print imperfection. 7 NM slight surface wear visible on close inspection, slight corner \ fraying, a minor print blemish. 6 EX-MT visible surface wear or a print defect. A very light scratch \ found only on close inspection. Graduated corner fraying. Minor \ edge chipping. 5 EX minor corner rounding becoming evident, more visible surface \ wear, minor chipping at the edges. 4 VG-EX slightly rounded corners with moderate fraying, light scuffing \ or scratching. 3 VG rounded corners, obvious surface wear and scratching. 2 GOOD badly frayed or rounded corners, advanced wear, creasing. 1 PR-FR heavy wear, major creasing, possible writing or tape. Rules that override the category-by-category read: - A CREASE is not ordinary surface wear. Any clear crease or fold caps a card \ in the low single digits (roughly 3 or below, 2 if pronounced) no matter how \ clean everything else looks. Because that verdict is so severe, do not reach \ it by elimination — separate a crease from a PRINT LINE deliberately, since \ the two look alike in a photo and are about five grades apart: A PRINT LINE is perfectly straight, of even thickness, runs parallel to an \ edge, and appears only on the printed side. The card is not deformed; the \ ink simply differs along that band. This is a print defect (PD), and on \ its own it does not stop a card grading in the 6-8 range. A CREASE breaks the card itself. It usually shows a paired light-and-dark \ line where the surface bends and catches light differently, tends to \ wander rather than run perfectly straight, often runs at an angle or fades \ out mid-card, and shows on BOTH sides — so if a back photo is supplied and \ the mark is absent there, it is almost certainly not a crease. When the evidence genuinely does not separate the two, say so and give the \ benefit of the doubt to the print line, noting that a back photo would \ settle it. Do not cap a card at 3 on a maybe. - A PRINT DEFECT is not handling damage. Print lines, dots, roller marks and \ slight colour registration errors happen at the factory, and PSA tolerates a \ minor one even at 10. Do not grade these like scratches and wear; note them \ separately. Severe ones do drag the grade and may earn a PD qualifier. - The BACK is graded too, and you usually cannot see it. When only a front \ photo is given, say so in limitations: a back-only flaw such as a wax stain \ or poor back centering is invisible to you and can pull the real grade below \ your estimate. This is a reason to keep the range open at the bottom. - Cards strong everywhere except one attribute may receive a QUALIFIER instead \ of a low grade — OC (off-centre), PD (print defect), ST (stain), MK (marks), \ MC (miscut). Worth mentioning when the pattern fits, since a "PSA 8 OC" is a \ different market proposition from a plain PSA 5. Vintage cards (roughly pre-1980) are graded on the same scale but almost never \ come back 9-10 — original cutting and centering were far less consistent, so \ temper the estimate accordingly rather than assuming a clean-looking vintage \ card is a high grade. Conversely, do not penalise a vintage card twice for the \ era-typical soft cut that its grade already accounts for. WHAT IS NORMAL FOR THIS KIND OF CARD PSA applies the SAME four criteria and the same centering tolerances to every \ card, so there is no separate rubric to switch to. What changes between card \ types is the base rate — how common a given flaw is, and therefore how much \ seeing it (or not seeing it) should move your estimate. Set card_type to what \ you actually see, then calibrate with the notes below. Do not report a flaw \ you cannot see just because it is common; this is about how to weigh what you \ DO see. If card_type is "sports": - Wax stains on the back exist only on wax-pack-era cards (roughly 1950s-80s). \ PSA explicitly tolerates a slight one even at 9. Never invent one; if a back \ photo shows a translucent greasy patch, that's what it is. - Print dots, snow and light print speckling are endemic to late-80s/early-90s \ mass-produced sets. A minor one is a print defect, not handling damage. - Centering on 1960s-70s Topps is notoriously poor — 70/30 or worse is typical \ rather than exceptional, and the measured ratio should drive the grade \ without extra editorialising about it. - Rough or "diamond" cuts are factory-normal on O-Pee-Chee and some older \ Topps. That's a cut characteristic, not edge wear. - 1971 Topps and other black-bordered sets show every speck of corner and edge \ wear. Judge the actual amount visible, not the visual impression the border \ creates. - Modern chrome stock (Prizm, Optic, Select, Topps Chrome) scratches readily; \ fine surface scratching is the norm and its absence is what's notable. - Tobacco-era cards (T206 and similar, pre-1920) were hand-cut and are \ essentially never well-centred or sharp-cornered. A 5 is a strong grade there. If card_type is "pokemon" or "other_tcg": - WOTC-era Pokemon holos (1999-2003, Base through Skyrim) have a holo layer \ that scratches extremely easily. Fine scratching across the holo window is \ close to universal; a genuinely clean one is unusual and worth saying so. - Dark and black-bordered sets (Team Rocket, Neo Destiny, older Magic) show \ edge whitening dramatically. Again, weigh the amount actually visible. - Modern Pokemon ultra-rares (VMAX, ex, full art, Trainer Gallery) have \ DELIBERATE textured or etched surfaces. That texture is manufacturing, not \ damage, and must never be reported as scratching or roughness. - Factory print lines are common on modern holo sheets — treat as a print \ defect (PD), not handling wear. See the crease-vs-print-line rule above. - Yu-Gi-Oh 1st Edition ultra/secret rares frequently bow or warp slightly from \ the foil layer. That is a manufacturing trait, NOT a crease or bend, and \ should not collapse the grade the way a real crease would. - Japanese Pokemon cards are generally better centred and better cut than \ their English counterparts, so a poorly centred Japanese card is a more \ meaningful finding than the same ratio on an English one. For each category give a severity: "none" (no issues visible), "minor", \ "moderate", "major", or "cannot_assess" when the photo genuinely doesn't \ support a judgement. Use "cannot_assess" freely — it is far more useful than \ a confident guess, and a "none" that really meant "I couldn't see any because \ the photo is too small" is actively misleading. Then give: - estimated_grade: your single best estimate, a whole number 1-10. - grade_low and grade_high: the realistic range this card could come back in, \ given what you could and couldn't assess. If you couldn't assess surface or \ centering, that range should be genuinely wide (e.g. 6-9), not cosmetic. - confidence: "high" only for a sharp, straight-on, high-resolution photo \ 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. - 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"). Never describe this as what the card *will* grade. It is an estimate of what \ it might grade, from a photo.""" GRADE_CATEGORY_SCHEMA = { "type": "object", "properties": { "severity": { "type": "string", "enum": ["none", "minor", "moderate", "major", "cannot_assess"], }, "observation": { "type": "string", "description": "What you specifically saw (or why you couldn't assess it).", }, }, "required": ["severity", "observation"], "additionalProperties": False, } GRADING_SCHEMA = { "type": "object", "properties": { "card_type": { "type": "string", "enum": ["pokemon", "sports", "other_tcg", "other"], "description": "Drives which base-rate notes apply; the rubric itself is identical.", }, "card_note": { "type": "string", "description": "One line naming the card if legible (player/name, set, year) — for the history list.", }, "estimated_grade": { "type": ["integer", "null"], "description": "Best single estimate, whole number 1-10, or null if ungradeable from these photos.", }, "grade_low": {"type": ["integer", "null"]}, "grade_high": {"type": ["integer", "null"]}, "confidence": {"type": "string", "enum": ["high", "medium", "low"]}, "centering": GRADE_CATEGORY_SCHEMA, "corners": GRADE_CATEGORY_SCHEMA, "edges": GRADE_CATEGORY_SCHEMA, "surface": GRADE_CATEGORY_SCHEMA, "limitations": { "type": "array", "items": {"type": "string"}, "description": "Specific things these photos prevented you from checking.", }, "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"], "additionalProperties": False, } def _clean_grade(value): if not isinstance(value, (int, float)) or isinstance(value, bool): return None return max(1, min(10, int(value))) def _clean_category(raw): raw = raw if isinstance(raw, dict) else {} severity = raw.get("severity") if severity not in ("none", "minor", "moderate", "major", "cannot_assess"): severity = "cannot_assess" return {"severity": severity, "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). `images` is a list of (image_bytes, filename) pairs. More angles help a lot here — front, back, and corner close-ups each unlock a category the others can't show. Returns the estimate, a range, per-category findings, and what the photos couldn't support judging. When `zoom_details` is on and Pillow is installed, magnified crops of the first photo's four corners and four edge strips are generated and sent alongside it. A corner or an edge band is a tiny part of a full-card frame, so after the model's own downscaling there is often too little left to judge — which is why those categories came back "cannot_assess" or missed visible whitening. Cropping first preserves that detail. """ supplied = len(images) crops = [] if zoom_details and images and cardimage.available(): crops = cardimage.detail_crops(images[0][0], images[0][1]) labels = [] for i in range(supplied): labels.append("FULL CARD photo{}:".format( "" if supplied == 1 else " {} of {}".format(i + 1, supplied))) for _, name in crops: stem = name.rsplit(".", 1)[0] if "-surface-" in stem: quadrant = stem.split("-surface-", 1)[1].upper() labels.append( "SURFACE INSPECTION of the {} QUADRANT. Left half: the crop as " "photographed. Right half: the same crop with the artwork " "cancelled out so only fine surface texture remains. Use the " "pair together to judge surface in this quadrant.".format(quadrant)) continue region = "-".join(stem.rsplit("-", 2)[-2:]) if region.endswith("-edge"): side = region[:-len("-edge")].upper() labels.append( "MAGNIFIED STRIP along the {} EDGE of the card. Real whitening " "is an UNEVEN pale band of varying width along the cut. The " "thin uniform line at the very boundary is the cut itself, " "which every card has — do not report that as whitening. " "Judge the {} edge from this image.".format(side, side.lower())) else: labels.append( "MAGNIFIED CLOSE-UP of the {} CORNER.".format(region.upper())) can_measure = zoom_details and images and cardimage.available() 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 prompt_parts = [] if centering and centering.get("reliable") is False: prompt_parts.append( "CENTERING COULD NOT BE MEASURED on this card, because {}. " "You are getting no border-width numbers, and on a card like " "this a border-width ratio is the wrong tool anyway: judge " "centering by eye as the printed design's alignment relative to " "the die-cut shape and the card's physical edges — that is what " "PSA actually keys off for die-cut issues. Do not reconstruct a " "left/right border ratio visually on a card with no uniform " "border; describe what you can genuinely see about the cut's " "registration to the printed pattern, and widen the range if " "the photo doesn't settle it.".format( centering.get("reason", "of its cut"))) elif centering: prompt_parts.append( "MEASURED CENTERING — computed from the pixels by finding the " "border on each side of the card:\n" " left/right: {} (borders {}px and {}px)\n" " top/bottom: {} (borders {}px and {}px)\n" " worst axis: {:.0f}/{:.0f}\n" "These are accurate to within about two percentage points, so use " "them directly against the PSA centering tolerances rather than " "estimating by eye. Disregard them only if the card is clearly " "photographed at an angle, since that distorts the border widths " "geometrically — say so if you think that is the case.".format( centering["horizontal_label"], centering["widths_px"]["left"], centering["widths_px"]["right"], centering["vertical_label"], centering["widths_px"]["top"], centering["widths_px"]["bottom"], centering["worst"], 100 - centering["worst"])) if measured and not measured.get("reliable"): prompt_parts.append( "EDGE WHITENING COULD NOT BE MEASURED on this card, because {}. " "You are getting no numbers for it, so judge the edges from the " "strip images alone — and judge them CONSERVATIVELY. On a border " "like this a pale edge is usually the border itself or a " "reflection rather than wear, so call whitening only where an " "uneven band clearly differs from the rest of that same edge. When " "unsure prefer cannot_assess over assuming wear: wrongly calling " "whitening costs the card a grade it should have kept.".format( measured.get("reason", "of its finish"))) elif measured: rows = [] note_rows = [] edge_notes = measured.get("edge_notes") or {} for side in ("top", "right", "bottom", "left"): data = (measured.get("edges") or {}).get(side) if data: rows.append(" {} edge: {:.1f}% of its length".format( side, data["percent"])) elif side in edge_notes: note_rows.append(" {} edge: NOT MEASURED — {}".format( side, edge_notes[side])) if note_rows: prompt_parts.append( "\n".join(note_rows) + "\n" "For any edge noted above as not measured due to a different " "material, judge it from its strip image alone, on its own " "terms as whatever that material is — do not read its " "brightness or lack of saturation as whitening just because " "it looks nothing like the card's other edges. Look instead " "for actual physical damage on that material: a crack or " "chip in a clear acetate window, foil peeling or flaking, a " "scuff that catches light differently than the surrounding " "area — the same kind of judgement as any other edge, just " "without a number to anchor it.") if rows: prompt_parts.append( "MEASURED EDGE WHITENING — computed directly from the pixels, " "not estimated by eye:\n" + "\n".join(rows) + "\n" "Each figure is the share of that edge whose outermost border " "is both lighter and less saturated than the SAME border a few " "pixels further in, which is the signature of paper core " "showing through. Because each column is compared against " "itself, it is unaffected by the border's colour, by the " "overall exposure, or by the cut line that every card has — " "the three things that make whitening so easy to misjudge by " "eye.\n" "Read them comparatively: an edge far above the others on the " "same card is the real finding. Roughly, under 15% is a clean " "edge; 15-30% is ambiguous and is as often a lighting " "highlight, a drop shadow, or a printed bevel along that side " "as it is wear, so do NOT call it wear unless you can also see " "an uneven pale band in that edge's strip image; 30-60% is " "clear whitening; above 60% is heavy. A card whose worst edge " "is under 15% has clean edges — say so plainly rather than " "hunting for something to report.\n" "Let these numbers lead the edges category, and use the edge " "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: standard_rows = "\n".join( " {}: {:.4f} standard → {:.1f}% deviation".format( name, data["standard_ratio"], data["deviation_percent"]) for name, data in aspect["against_standards"].items()) prompt_parts.append( "MEASURED CARD PROPORTIONS — the width:height ratio of the card's " "own detected outline in this photo, computed from the pixels: " "{:.4f}. Compared against every standard card size, since which " "one is relevant depends on the card's era, which is for you to " "judge, not this measurement:\n{}\n" "Use the deviation against whichever standard actually matches " "the card type and era you're identifying it as — a modern card " "compared against the tobacco-era ratio, or vice versa, will show " "a large 'deviation' that means nothing at all.\n" "Unlike the centering and edge numbers above, this one comes with " "NO reliability check already applied, because the code cannot " "tell a genuinely non-standard card apart from one simply " "photographed at a slight rotation — both inflate the measured " "box the same way. That call needs the photo, which only you " "have: look at whether the card actually sits square in the " "frame before trusting this number at all. A deviation under " "roughly 3% against the RELEVANT standard is normal photo-crop " "noise and means nothing either way. A larger one on a photo that " "looks square-on is worth connecting to the corner-squareness " "guidance above — it can corroborate a miscut or trimming " "suspicion you already have visual grounds for, but it should " "never be the ONLY reason you raise one; a rotated or " "slightly-cropped photo produces exactly the same number on a " "perfectly normal card. This only catches UNEVEN trimming (more " "removed from one side than another) — it cannot see a symmetric " "trim taken off all four sides evenly.".format( aspect["measured_ratio"], standard_rows)) if crops: prompt_parts.append( "The close-ups above are upscaled crops of the full card photo, each " "captioned with the exact region it came from — trust those captions " "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.") prompt_parts.append("Estimate the PSA grade this trading card would likely receive.") parsed, usage = _call_vision( list(images) + crops, GRADING_SYSTEM, GRADING_SCHEMA, " ".join(prompt_parts), api_key, model, effort, max_tokens=MAX_TOKENS, labels=labels, ) low = _clean_grade(parsed.get("grade_low")) high = _clean_grade(parsed.get("grade_high")) if low is not None and high is not None and low > high: low, high = high, low card_type = parsed.get("card_type") if card_type not in ("pokemon", "sports", "other_tcg", "other"): card_type = "other" return { "closeups": len(crops), "card_type": card_type, "card_note": (parsed.get("card_note") or "").strip(), "edge_measurements": measured, "centering_measurement": centering, "aspect_measurement": aspect, "estimated_grade": _clean_grade(parsed.get("estimated_grade")), "grade_low": low, "grade_high": high, "confidence": parsed.get("confidence") or "low", "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], "note": parsed.get("note") or "", "usage": usage, } def current_rates(caps, on_date=None): """(input, output) $/Mtok for a model, honouring any scheduled change. A model on introductory pricing carries the dated rates that supersede it in `priced_from`; the latest one whose date has passed wins. Without this the table silently keeps quoting a rate that no longer exists once the promotion ends, and every cost shown in the app is wrong by whatever the increase was. """ rate_in, rate_out = caps["in_per_mtok"], caps["out_per_mtok"] today = on_date or time.strftime("%Y-%m-%d") for starts, new_in, new_out in sorted(caps.get("priced_from") or []): if today >= starts: rate_in, rate_out = new_in, new_out return rate_in, rate_out def estimate_cost(usage): """Actual USD cost of one grading call, from the reported token counts.""" if not usage: return None caps = MODELS.get(usage.get("model") or "", MODELS[DEFAULT_MODEL]) rate_in, rate_out = current_rates(caps) return ((usage.get("input_tokens") or 0) * rate_in / 1_000_000 + (usage.get("output_tokens") or 0) * rate_out / 1_000_000) def price_guide(): """Per-model cost of a typical grading call (1 photo + 12 close-ups).""" guide = {} for model_id, caps in MODELS.items(): # 1 supplied photo + ~12 generated close-ups, JSON verdict out. est_in = caps["approx_image_tokens"] * 5 + 600 est_out = 700 rate_in, rate_out = current_rates(caps) guide[model_id] = { "label": caps["label"], "provider": caps.get("provider", "anthropic"), "supports_effort": caps["effort"], "per_grade": round(est_in * rate_in / 1_000_000 + est_out * rate_out / 1_000_000, 4), } return guide