The pooled baseline that scores edge whitening was global across all four edges, so a card that's normal printed border on three sides and clear acetate on the fourth had the three normal edges anchor a baseline that made the clear side read as extreme whitening -- correctly detecting a real pixel difference, just the wrong one. Now excludes an edge from both the pool and its own scoring when its median brightness/saturation reads as a fundamentally different material, and surfaces why in the prompt instead of silently dropping it.
828 lines
43 KiB
Python
828 lines
43 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 = []
|
|
note_rows = []
|
|
edge_notes = measured.get("edge_notes") or {}
|
|
for side in ("top", "right", "bottom", "left"):
|
|
data = (measured.get("edges") or {}).get(side)
|
|
if data:
|
|
rows.append(" {} edge: {:.1f}% of its length".format(
|
|
side, data["percent"]))
|
|
elif side in edge_notes:
|
|
note_rows.append(" {} edge: NOT MEASURED — {}".format(
|
|
side, edge_notes[side]))
|
|
if note_rows:
|
|
prompt_parts.append(
|
|
"\n".join(note_rows) + "\n"
|
|
"For any edge noted above as not measured due to a different "
|
|
"material, judge it from its strip image alone, on its own "
|
|
"terms as whatever that material is — do not read its "
|
|
"brightness or lack of saturation as whitening just because "
|
|
"it looks nothing like the card's other edges. Look instead "
|
|
"for actual physical damage on that material: a crack or "
|
|
"chip in a clear acetate window, foil peeling or flaking, a "
|
|
"scuff that catches light differently than the surrounding "
|
|
"area — the same kind of judgement as any other edge, just "
|
|
"without a number to anchor it.")
|
|
if rows:
|
|
prompt_parts.append(
|
|
"MEASURED EDGE WHITENING — computed directly from the pixels, "
|
|
"not estimated by eye:\n" + "\n".join(rows) + "\n"
|
|
"Each figure is the share of that edge whose outermost border "
|
|
"is both lighter and less saturated than the SAME border a few "
|
|
"pixels further in, which is the signature of paper core "
|
|
"showing through. Because each column is compared against "
|
|
"itself, it is unaffected by the border's colour, by the "
|
|
"overall exposure, or by the cut line that every card has — "
|
|
"the three things that make whitening so easy to misjudge by "
|
|
"eye.\n"
|
|
"Read them comparatively: an edge far above the others on the "
|
|
"same card is the real finding. Roughly, under 15% is a clean "
|
|
"edge; 15-30% is ambiguous and is as often a lighting "
|
|
"highlight, a drop shadow, or a printed bevel along that side "
|
|
"as it is wear, so do NOT call it wear unless you can also see "
|
|
"an uneven pale band in that edge's strip image; 30-60% is "
|
|
"clear whitening; above 60% is heavy. A card whose worst edge "
|
|
"is under 15% has clean edges — say so plainly rather than "
|
|
"hunting for something to report.\n"
|
|
"Let these numbers lead the edges category, and use the edge "
|
|
"strip images to describe what the wear looks like and to "
|
|
"catch what the measurement does not look for at all, such as "
|
|
"a nick, a chip, or a crushed edge.")
|
|
if aspect:
|
|
standard_rows = "\n".join(
|
|
" {}: {:.4f} standard → {:.1f}% deviation".format(
|
|
name, data["standard_ratio"], data["deviation_percent"])
|
|
for name, data in aspect["against_standards"].items())
|
|
prompt_parts.append(
|
|
"MEASURED CARD PROPORTIONS — the width:height ratio of the card's "
|
|
"own detected outline in this photo, computed from the pixels: "
|
|
"{:.4f}. Compared against every standard card size, since which "
|
|
"one is relevant depends on the card's era, which is for you to "
|
|
"judge, not this measurement:\n{}\n"
|
|
"Use the deviation against whichever standard actually matches "
|
|
"the card type and era you're identifying it as — a modern card "
|
|
"compared against the tobacco-era ratio, or vice versa, will show "
|
|
"a large 'deviation' that means nothing at all.\n"
|
|
"Unlike the centering and edge numbers above, this one comes with "
|
|
"NO reliability check already applied, because the code cannot "
|
|
"tell a genuinely non-standard card apart from one simply "
|
|
"photographed at a slight rotation — both inflate the measured "
|
|
"box the same way. That call needs the photo, which only you "
|
|
"have: look at whether the card actually sits square in the "
|
|
"frame before trusting this number at all. A deviation under "
|
|
"roughly 3% against the RELEVANT standard is normal photo-crop "
|
|
"noise and means nothing either way. A larger one on a photo that "
|
|
"looks square-on is worth connecting to the corner-squareness "
|
|
"guidance above — it can corroborate a miscut or trimming "
|
|
"suspicion you already have visual grounds for, but it should "
|
|
"never be the ONLY reason you raise one; a rotated or "
|
|
"slightly-cropped photo produces exactly the same number on a "
|
|
"perfectly normal card. This only catches UNEVEN trimming (more "
|
|
"removed from one side than another) — it cannot see a symmetric "
|
|
"trim taken off all four sides evenly.".format(
|
|
aspect["measured_ratio"], standard_rows))
|
|
if crops:
|
|
prompt_parts.append(
|
|
"The close-ups above are upscaled crops of the full card photo, each "
|
|
"captioned with the exact region it came from — trust those captions "
|
|
"when you say which corner or edge a problem is on. They add no "
|
|
"information the full photo lacked, only easier viewing, so do not "
|
|
"read resampling softness as card wear.")
|
|
prompt_parts.append("Estimate the PSA grade this trading card would likely receive.")
|
|
|
|
parsed, usage = _call_vision(
|
|
list(images) + crops, GRADING_SYSTEM, GRADING_SCHEMA, " ".join(prompt_parts),
|
|
api_key, model, effort, max_tokens=MAX_TOKENS, labels=labels,
|
|
)
|
|
|
|
low = _clean_grade(parsed.get("grade_low"))
|
|
high = _clean_grade(parsed.get("grade_high"))
|
|
if low is not None and high is not None and low > high:
|
|
low, high = high, low
|
|
|
|
card_type = parsed.get("card_type")
|
|
if card_type not in ("pokemon", "sports", "other_tcg", "other"):
|
|
card_type = "other"
|
|
|
|
return {
|
|
"closeups": len(crops),
|
|
"card_type": card_type,
|
|
"card_note": (parsed.get("card_note") or "").strip(),
|
|
"edge_measurements": measured,
|
|
"centering_measurement": centering,
|
|
"aspect_measurement": aspect,
|
|
"estimated_grade": _clean_grade(parsed.get("estimated_grade")),
|
|
"grade_low": low,
|
|
"grade_high": high,
|
|
"confidence": parsed.get("confidence") or "low",
|
|
"categories": {
|
|
"centering": _clean_category(parsed.get("centering")),
|
|
"corners": _clean_category(parsed.get("corners")),
|
|
"edges": _clean_category(parsed.get("edges")),
|
|
"surface": _clean_category(parsed.get("surface")),
|
|
},
|
|
"limitations": [l for l in (parsed.get("limitations") or []) if l],
|
|
"note": parsed.get("note") or "",
|
|
"usage": usage,
|
|
}
|
|
|
|
|
|
def 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
|