Initial commit: Card Grader deployed to hippofam.com/cards
PWA card-grading app, deployed behind Nginx Proxy Manager on Unraid with basic auth. Includes CARD_GRADER_BASE_PATH support for running under a sub-path, and Docker/compose config for the Unraid deployment.
This commit is contained in:
commit
c7bd71a3e1
19 changed files with 3618 additions and 0 deletions
719
vision.py
Normal file
719
vision.py
Normal file
|
|
@ -0,0 +1,719 @@
|
|||
"""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.
|
||||
- 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.
|
||||
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
|
||||
|
||||
prompt_parts = []
|
||||
if centering:
|
||||
prompt_parts.append(
|
||||
"MEASURED CENTERING — computed from the pixels by finding the "
|
||||
"border on each side of the card:\n"
|
||||
" left/right: {} (borders {}px and {}px)\n"
|
||||
" top/bottom: {} (borders {}px and {}px)\n"
|
||||
" worst axis: {:.0f}/{:.0f}\n"
|
||||
"These are accurate to within about two percentage points, so use "
|
||||
"them directly against the PSA centering tolerances rather than "
|
||||
"estimating by eye. Disregard them only if the card is clearly "
|
||||
"photographed at an angle, since that distorts the border widths "
|
||||
"geometrically — say so if you think that is the case.".format(
|
||||
centering["horizontal_label"],
|
||||
centering["widths_px"]["left"], centering["widths_px"]["right"],
|
||||
centering["vertical_label"],
|
||||
centering["widths_px"]["top"], centering["widths_px"]["bottom"],
|
||||
centering["worst"], 100 - centering["worst"]))
|
||||
if measured and not measured.get("reliable"):
|
||||
prompt_parts.append(
|
||||
"EDGE WHITENING COULD NOT BE MEASURED on this card, because {}. "
|
||||
"You are getting no numbers for it, so judge the edges from the "
|
||||
"strip images alone — and judge them CONSERVATIVELY. On a border "
|
||||
"like this a pale edge is usually the border itself or a "
|
||||
"reflection rather than wear, so call whitening only where an "
|
||||
"uneven band clearly differs from the rest of that same edge. When "
|
||||
"unsure prefer cannot_assess over assuming wear: wrongly calling "
|
||||
"whitening costs the card a grade it should have kept.".format(
|
||||
measured.get("reason", "of its finish")))
|
||||
elif measured:
|
||||
rows = []
|
||||
for side in ("top", "right", "bottom", "left"):
|
||||
data = (measured.get("edges") or {}).get(side)
|
||||
if data:
|
||||
rows.append(" {} edge: {:.1f}% of its length".format(
|
||||
side, data["percent"]))
|
||||
if rows:
|
||||
prompt_parts.append(
|
||||
"MEASURED EDGE WHITENING — computed directly from the pixels, "
|
||||
"not estimated by eye:\n" + "\n".join(rows) + "\n"
|
||||
"Each figure is the share of that edge whose outermost border "
|
||||
"is both lighter and less saturated than the SAME border a few "
|
||||
"pixels further in, which is the signature of paper core "
|
||||
"showing through. Because each column is compared against "
|
||||
"itself, it is unaffected by the border's colour, by the "
|
||||
"overall exposure, or by the cut line that every card has — "
|
||||
"the three things that make whitening so easy to misjudge by "
|
||||
"eye.\n"
|
||||
"Read them comparatively: an edge far above the others on the "
|
||||
"same card is the real finding. Roughly, under 15% is a clean "
|
||||
"edge; 15-30% is ambiguous and is as often a lighting "
|
||||
"highlight, a drop shadow, or a printed bevel along that side "
|
||||
"as it is wear, so do NOT call it wear unless you can also see "
|
||||
"an uneven pale band in that edge's strip image; 30-60% is "
|
||||
"clear whitening; above 60% is heavy. A card whose worst edge "
|
||||
"is under 15% has clean edges — say so plainly rather than "
|
||||
"hunting for something to report.\n"
|
||||
"Let these numbers lead the edges category, and use the edge "
|
||||
"strip images to describe what the wear looks like and to "
|
||||
"catch what the measurement does not look for at all, such as "
|
||||
"a nick, a chip, or a crushed edge.")
|
||||
if 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,
|
||||
"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
|
||||
Loading…
Add table
Add a link
Reference in a new issue