card-grader/vision.py
Barely Removable 3cacf2f292 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).
2026-08-23 11:43:36 -07:00

1097 lines
57 KiB
Python

"""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.
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 \
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.
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").
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,
}
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": {
"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.",
},
"authenticity": AUTHENTICITY_SCHEMA,
"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", "authenticity", "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 _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).
`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"
confidence = parsed.get("confidence") or "low"
limitations = [l for l in (parsed.get("limitations") or []) if l]
# "high" confidence is defined to the model as being able to confidently
# assess all four categories — but centering and edge whitening are the
# two this app can measure from pixels rather than ask the model to
# judge by eye, and a self-reported "high" doesn't reliably account for
# whether that measurement was actually available on THIS card. Capped
# here rather than left to the prompt alone: the model juggles a long
# instruction list already, and this is exactly the kind of objective,
# checkable fact code should enforce rather than hope gets weighed
# correctly every time. Only caps a "high" claim down to "medium" when
# NEITHER was measured — one of two still measured is a real basis the
# model may legitimately be confident from, so that's left to its own
# judgment.
def _measured_ok(m):
return bool(m) and m.get("reliable", True) is not False
if confidence == "high" and not _measured_ok(centering) and not _measured_ok(measured):
confidence = "medium"
limitations.append(
"Confidence capped at medium: neither centering nor edge "
"whitening could be measured from the pixels on this photo, so "
"the estimate leans on the photo alone for two of PSA's four "
"categories rather than a direct pixel measurement for either.")
return {
"closeups": len(crops),
"card_type": card_type,
"card_note": (parsed.get("card_note") or "").strip(),
"authenticity": _clean_authenticity(parsed.get("authenticity")),
"edge_measurements": measured,
"centering_measurement": centering,
"aspect_measurement": aspect,
"estimated_grade": _clean_grade(parsed.get("estimated_grade")),
"grade_low": low,
"grade_high": high,
"confidence": confidence,
"categories": {
"centering": _clean_category(parsed.get("centering")),
"corners": _clean_category(parsed.get("corners")),
"edges": _clean_category(parsed.get("edges")),
"surface": _clean_category(parsed.get("surface")),
},
"limitations": limitations,
"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