card-grader/vision.py

810 lines
42 KiB
Python

"""Estimate a trading card's PSA grade from photographs, using Claude's vision.
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 cardimage
try:
import anthropic
except ImportError: # keeps the rest of the app importable without the SDK
anthropic = 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",
"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",
"effort": True,
"adaptive_thinking": True,
"fallbacks": False,
# Introductory pricing runs through 2026-08-31, then 3.00 / 15.00.
"in_per_mtok": 2.00, "out_per_mtok": 10.00,
"approx_image_tokens": 4800,
},
"claude-opus-5": {
"label": "Opus 5 — most accurate",
"effort": True,
"adaptive_thinking": True,
"fallbacks": True,
"in_per_mtok": 5.00, "out_per_mtok": 25.00,
"approx_image_tokens": 4800,
},
}
# 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?"""
return anthropic is not None and bool(_api_key())
def _api_key():
return os.environ.get("ANTHROPIC_API_KEY", "").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 _call_vision(images, system, schema, prompt, api_key=None, model=None,
effort=None, max_tokens=None, labels=None):
"""Shared plumbing for every vision call: auth, request shape per model
capability, and error/refusal handling. Returns (parsed_json, usage_dict).
Raises VisionError with a human-readable message on any failure — callers
surface it in the UI rather than half-committing anything.
"""
if anthropic is None:
raise VisionError(
"The anthropic package isn't installed. Run: "
"python3 -m pip install --user anthropic"
)
if not images:
raise VisionError("No images to analyze.")
key = (api_key or _api_key()) or None
if not key:
raise VisionError(
"No Anthropic API key set. Add one in Settings, or export "
"ANTHROPIC_API_KEY before starting the app."
)
model = model if model in MODELS else DEFAULT_MODEL
caps = MODELS[model]
effort = effort or DEFAULT_EFFORT
client = anthropic.Anthropic(api_key=key)
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({"type": "image",
"source": {"type": "base64", "media_type": mime, "data": encoded}})
content.append({"type": "text", "text": prompt})
params = {
"model": model,
"max_tokens": max_tokens or 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,
}
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.
- 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:
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 = []
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"]))
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 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])
return ((usage.get("input_tokens") or 0) * caps["in_per_mtok"] / 1_000_000
+ (usage.get("output_tokens") or 0) * caps["out_per_mtok"] / 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
guide[model_id] = {
"label": caps["label"],
"supports_effort": caps["effort"],
"per_grade": round(est_in * caps["in_per_mtok"] / 1_000_000
+ est_out * caps["out_per_mtok"] / 1_000_000, 4),
}
return guide