The framing caveat added earlier told the model to find the card's real edge inside each mis-cut crop, then immediately offered 'say cannot_assess if the close-up doesn't clearly contain it' — wrapped in alarming language about cases. On a Fleer Ultra Jordan shot in a clear toploader it took the escape hatch for corners AND edges, returning three of four categories as 'can't tell from photo' on a card whose corners are plainly visible through the plastic. That was a regression I introduced, not a limit of the photo. A toploader is transparent: corner sharpness, corner whitening, edge chipping and edge whitening all read through it, and the card's cut line is visible as a distinct boundary inside the holder's (confirmed in the pixel profile — the card's own edge shows as a clear step ~75px inside the detected holder boundary). Those categories now get judged normally, with cannot_assess reserved for genuinely hidden geometry. Surface keeps the escape hatch, because there it's correct: scratches, dust and glare on a holder sit directly over the card's surface and can't be separated from it in a photo.
1141 lines
60 KiB
Python
1141 lines
60 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,
|
|
# Calibrated against 8 real single/two-photo grades once this model
|
|
# had actually been used (median 18.4k in / 1.2k out). The initial
|
|
# 1500 was a guess made before any real call existed and advertised
|
|
# roughly 2.2x under the true cost — a guess is fine to start from,
|
|
# but it has to be trued up once real usage exists, exactly as the
|
|
# Anthropic figures were.
|
|
"approx_image_tokens": 3560,
|
|
"approx_output_tokens": 1180,
|
|
},
|
|
}
|
|
|
|
# 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 ............. 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
|
|
PSA additionally allows a 5% LEEWAY on these front minimums for cards \
|
|
grading 7 or better — so a 10's 55/45 can stretch toward 60/40, a 9's \
|
|
60/40 toward 65/35, and so on up to a 7. That leeway is the grader's \
|
|
discretion and keys off overall eye appeal, not an automatic second \
|
|
tolerance: a card just past the published line is genuinely borderline \
|
|
rather than either automatically passing or automatically failing, so \
|
|
give the range at that boundary rather than a confident single grade. No \
|
|
leeway applies at 6 or below.
|
|
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
|
|
# Same detection failure that makes the measurements refuse also
|
|
# mis-frames every crop — see cardimage.framing_warning.
|
|
framing = cardimage.framing_warning(images[0][0]) if (crops or 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 and aspect.get("reliable") is not False:
|
|
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.")
|
|
if framing:
|
|
prompt_parts.append(
|
|
"CAVEAT ON THOSE CROPS: {} The crop rectangle comes from that "
|
|
"same detection, so each close-up is cut against the wrong "
|
|
"boundary — most often because the card is in a toploader, "
|
|
"sleeve or case and the detection found the HOLDER's outline "
|
|
"instead of the card's. The card's own corner or edge is "
|
|
"therefore sitting inside the frame rather than at the crop's "
|
|
"own corner. Locate it and judge that.\n"
|
|
"Being in a holder is NOT by itself a reason to give up on "
|
|
"corners or edges. A toploader or penny sleeve is clear "
|
|
"plastic: corner sharpness, corner whitening, edge chipping "
|
|
"and edge whitening all read straight through it, and the "
|
|
"card's cut line is normally plainly visible as a distinct "
|
|
"boundary inside the holder's. Judge those categories "
|
|
"normally — just make sure you are reading the CARD's "
|
|
"boundary and not the holder's, and never count scuffing, "
|
|
"scratching or whitening that belongs to the plastic as "
|
|
"damage to the card. Reserve cannot_assess for a corner or "
|
|
"edge genuinely hidden — obscured by a label, blown out by "
|
|
"glare, or out of frame — not merely for being behind clear "
|
|
"plastic.\n"
|
|
"SURFACE is the real exception. Scratches, dust, haze and "
|
|
"glare on a holder sit directly over the card's own surface "
|
|
"and cannot be separated from it in a photo, so "
|
|
"cannot_assess IS the honest answer for surface on a card "
|
|
"shot through a scratched or dusty holder — say so plainly "
|
|
"and name the holder as the reason.".format(framing))
|
|
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.
|
|
# Per-model output estimate where one is known: GPT-5.6 Sol
|
|
# reliably writes ~1.2k tokens of verdict against Sonnet's ~0.9k,
|
|
# enough to matter at these prices.
|
|
est_in = caps["approx_image_tokens"] * 5 + 600
|
|
est_out = caps.get("approx_output_tokens", 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
|