card-grader/cardimage.py
Barely Removable 0cbeb26665 Audit round 2: aspect measured the case too; defensive image decode; own-key state reset
4. aspect_profile had the same root cause as edges/centering but no guard.
   On the cased photo it reported 5.8% off standard -- the CASE's
   proportions -- and the UI would surface that as a trimming hint about a
   card it never measured. This one is more directly load-bearing than the
   other two, since the measurement IS the detected box. Now refuses, with
   both consumers (prompt block and UI hint) checking reliable.

5. _decode_images could KeyError/raise out of a plain read on a malformed
   row, and separately b64decode returns b'' rather than raising on some
   corrupt input -- which would have fed a zero-byte 'photo' into grading
   to fail confusingly deep in the pipeline. Both now degrade to the
   existing re-pick path.

6. _last_used_own_key is instance state on a handler that serves every
   request on a keep-alive connection, so it outlives the request that set
   it. Currently safe (every logging path assigns first), but a future path
   that logged without reaching the assignment would bill the previous
   request's payer. Cleared up-front now.

Full regression suite re-run: die-cut exclusion, uniform cards, centering,
aspect, cased-photo refusals, and the store layer all still behave.
2026-08-25 08:05:33 -07:00

1162 lines
53 KiB
Python

"""Corner close-ups for grading, cut out of a full-card photo.
Corners are the category the grader struggled with most, and the reason is
mechanical rather than a prompt problem: a card corner is a tiny fraction of
the frame, so once a full-card photo is scaled down for the vision model
there are barely any pixels left where the whitening and fraying actually
live. A human grader solves this with a loupe. This does the same thing —
find the card in the photo, cut out each of the four corners, and upscale
them into their own images so the detail survives.
Pillow is an optional dependency. Without it everything still works, just
without the close-ups, so this never becomes a hard requirement.
"""
import io
try:
from PIL import Image, ImageChops, ImageFilter, ImageOps
except ImportError:
Image = None
ImageChops = None
ImageFilter = None
ImageOps = None
# Fraction of the card's width/height each corner crop covers. A card corner's
# actual wear lives in the outer few millimetres, but a crop that tight loses
# the context needed to judge whether an edge is cut straight.
CORNER_FRACTION = 0.28
# How deep an edge strip reaches into the card, as a fraction of the
# perpendicular dimension. Kept deliberately shallow: whitening sits in the
# outermost millimetre or two, so a deeper strip is mostly card interior and
# the wear ends up a sliver at one end of a frame full of artwork — which is
# exactly how it gets overlooked. Shallow enough that the cut edge dominates
# what's on screen, with just enough border either side to give it context.
EDGE_FRACTION = 0.07
# Upscale target for the long edge of each crop. Large enough that fine
# whitening survives, small enough to stay well inside the model's per-image
# cap (and its token cost).
CORNER_TARGET_PX = 700
# Edge strips are long and thin, so magnifying them by their LONG axis (the
# way a squarish corner crop is handled) does nothing useful — that axis is
# already big. What matters is how many pixels lie across the strip, since
# that's the direction a whitening band is measured in. So these target the
# short axis, with a cap on the long one to stay inside the model's per-image
# pixel limit.
EDGE_SHORT_TARGET_PX = 340
EDGE_LONG_CAP_PX = 2500
# Surface inspection, as a band-pass rather than a plain high-pass. A plain
# high-pass keeps the very finest detail, which on a printed card means the
# halftone dot rosettes — they swamp the picture and hide the very marks
# being looked for. Scratches and print lines sit in a band between those
# dots and the artwork itself, so the fine radius blurs the dots away and
# the coarse one takes the artwork out, leaving what's in between.
SURFACE_FINE_RADIUS = 1.4
SURFACE_COARSE_RADIUS = 6.0
SURFACE_AUTOCONTRAST_CUTOFF = 0.4
# The card face is split into quadrants for surface inspection, so each one
# keeps enough resolution to show a hairline scratch. Rows x columns.
SURFACE_TILES = (2, 2)
SURFACE_TILE_TARGET_PX = 1150
SURFACE_QUADRANTS = ("upper-left", "upper-right", "lower-left", "lower-right")
# Below this the source photo has no detail worth zooming into — upscaling it
# would just produce a convincing-looking blur for the model to over-read.
MIN_SOURCE_PX = 600
CORNERS = ("top-left", "top-right", "bottom-left", "bottom-right")
EDGES = ("top-edge", "right-edge", "bottom-edge", "left-edge")
# Short-side/long-side ratios for card stock sizes actually in circulation.
# Trimming is compared against whichever of these is closest, not one fixed
# number — treating every card as one standard size would flag genuinely
# factory-cut cards (a tobacco-era T206, a wide 1930s strip card) as trimmed
# just for being a different shape than a modern card.
STANDARD_ASPECT_RATIOS = {
"modern (2.5\" x 3.5\", most post-1957 issues)": 2.5 / 3.5,
"tobacco-era (roughly 1.5\" x 2.5\", T206 and similar pre-1920s)": 1.5 / 2.5,
"wide vintage (roughly 2.0\" x 3.0\", some 1930s-50s strip/premium issues)": 2.0 / 3.0,
}
def available():
return Image is not None
# Magic numbers, so an image is identified by what it actually is rather than
# by what its filename claims. Phones routinely hand over names the extension
# check can't cope with — no extension at all, a content:// URI, or .HEIC —
# and rejecting a perfectly readable photo over its name is indefensible.
_SIGNATURES = (
(b"\x89PNG\r\n\x1a\n", "png"),
(b"\xff\xd8\xff", "jpeg"),
(b"GIF87a", "gif"),
(b"GIF89a", "gif"),
(b"BM", "bmp"),
(b"II*\x00", "tiff"),
(b"MM\x00*", "tiff"),
)
# Formats the Anthropic API accepts directly; anything else has to be
# converted before it can be sent.
DIRECTLY_SUPPORTED = {"png", "jpeg", "gif", "webp"}
def sniff_format(image_bytes):
"""Identify an image from its leading bytes. Returns a short name or None."""
if not image_bytes:
return None
head = image_bytes[:32]
for signature, name in _SIGNATURES:
if head.startswith(signature):
return name
if head[:4] == b"RIFF" and head[8:12] == b"WEBP":
return "webp"
# HEIC/HEIF (iPhone's default) declares itself in an 'ftyp' box.
if head[4:8] == b"ftyp":
brand = head[8:12]
if brand in (b"heic", b"heix", b"hevc", b"hevx", b"mif1", b"msf1", b"heim"):
return "heic"
if brand in (b"avif", b"avis"):
return "avif"
return None
def normalize_upload(image_bytes, filename="upload"):
"""Return (bytes, filename, error) with the image in a sendable format.
Passes through anything the API already accepts. Anything else that
Pillow can open — HEIC with the right plugin, BMP, TIFF, AVIF — is
re-encoded as JPEG rather than refused, since the bytes are perfectly
good and only the container is wrong.
"""
fmt = sniff_format(image_bytes)
if fmt in DIRECTLY_SUPPORTED:
stem = filename.rsplit(".", 1)[0] if "." in filename else filename
return image_bytes, "{}.{}".format(stem or "upload", "jpg" if fmt == "jpeg" else fmt), None
if Image is None:
return None, None, (
"That file is {} and this app can only send PNG, JPEG, GIF or WebP. "
"Installing pillow (python3 -m pip install --user pillow) would let "
"it convert automatically.".format(fmt or "an unrecognised format"))
try:
img = Image.open(io.BytesIO(image_bytes))
img.load()
if img.mode not in ("RGB", "L"):
img = img.convert("RGB")
buffer = io.BytesIO()
img.save(buffer, format="JPEG", quality=92)
stem = filename.rsplit(".", 1)[0] if "." in filename else filename
return buffer.getvalue(), "{}.jpg".format(stem or "upload"), None
except Exception:
if fmt in ("heic", "avif"):
return None, None, (
"That photo is {} format, which needs an extra decoder. Either "
"install it (python3 -m pip install --user pillow-heif) or set "
"your phone's camera to save JPEG instead of "
"High Efficiency.".format(fmt.upper()))
return None, None, (
"Couldn't read that file — it doesn't look like a readable image.")
def _detect_card_box(img):
"""Best-effort bounding box of the card within the photo.
Works by estimating the background colour from the photo's own corners
and then finding the rows and columns that stop looking like background.
That beats edge-density detection here: a card's interior is full of
artwork and text, so edge density peaks in the middle and gives a box
that drifts several percent past the real cut line. For edge strips that
slop matters — it fills the strip with desk or mat instead of the card's
border, which is the whole thing being examined.
Returns None when the result doesn't look like a card, so the caller can
fall back to treating the whole frame as the card.
"""
rgb = img.convert("RGB")
# Detect at a fairly high resolution. Downscaling harder is cheaper, but
# it blurs the outermost pixels of the card into the background — and on
# a pale background, a heavily whitened edge then reads AS background, so
# the boundary walks inward past the wear and the measurement misses the
# very thing it is looking for. Found exactly that way round in testing.
scale = 800.0 / max(rgb.size)
if scale < 1:
rgb = rgb.resize((max(1, int(rgb.width * scale)),
max(1, int(rgb.height * scale))), Image.BILINEAR)
else:
scale = 1.0
w, h = rgb.size
if w < 20 or h < 20:
return None
px = rgb.load()
# Estimate the background from the four image corners. If the card fills
# the frame these samples are card, every pixel then reads as "not
# background", and the box correctly comes back as the whole image.
patch = max(2, min(w, h) // 25)
samples = []
for cx, cy in ((0, 0), (w - patch, 0), (0, h - patch), (w - patch, h - patch)):
for x in range(cx, min(w, cx + patch)):
for y in range(cy, min(h, cy + patch)):
samples.append(px[x, y])
bg = tuple(sum(c[i] for c in samples) // len(samples) for i in range(3))
def differs(p):
return abs(p[0] - bg[0]) + abs(p[1] - bg[1]) + abs(p[2] - bg[2]) > 90
row_counts = [0] * h
col_counts = [0] * w
for y in range(h):
for x in range(w):
if differs(px[x, y]):
row_counts[y] += 1
col_counts[x] += 1
def span(counts, extent):
# A real card edge makes most of a row/column stop being background
# at once, so key off a share of the perpendicular extent rather than
# off the peak — that keeps a few stray specks of noise in the
# background from widening the box.
cutoff = extent * 0.35
hits = [i for i, c in enumerate(counts) if c >= cutoff]
return (hits[0], hits[-1]) if hits else None
rows, cols = span(row_counts, w), span(col_counts, h)
if not rows or not cols:
return None
box = (int(cols[0] / scale), int(rows[0] / scale),
int(round(cols[1] / scale)), int(round(rows[1] / scale)))
box = (max(0, box[0]), max(0, box[1]),
min(img.width, box[2] + 1), min(img.height, box[3] + 1))
bw, bh = box[2] - box[0], box[3] - box[1]
if bw < 40 or bh < 40:
return None
# A tiny box means detection latched onto something that isn't the card.
if float(bw * bh) / float(img.width * img.height) < 0.15:
return None
return box
def _box_margin_reason(box, img_size):
"""None if the detected box leaves a plausible margin on enough sides
to trust; otherwise the reason it doesn't.
Exists because of a real failure: a card photographed still inside a
black display case, where the case filled the whole frame with no
visible background anywhere. _detect_card_box's background-from-corners
approach has nothing to key off in that situation, and it isn't obvious
from the box alone — it returned (0, 23, 985, 1327) on a 986x1346
photo, which LOOKS like a plausible tight crop, not an obvious failure
like the too-small-area case already guarded against above. But at
essentially zero margin, every downstream measurement's outer/inner
sampling bands — a few percent of the card's own short dimension, by
design, since real wear lives in the outermost sliver — land entirely
inside whatever surrounds the true card, never reaching it. On that
photo they read the case's own embossed texture as "whitening": no
single edge disagreed with the others (the case is uniformly dark on
all four sides), so the per-edge consistency checks elsewhere in this
file never saw a reason to object.
Two real situations produce a near-zero margin, and there's no reliable
way to tell them apart from pixels alone: a card genuinely photographed
edge-to-edge with nothing but card in frame, or something surrounding
the card being read as part of it. Refusing both is the safer
direction — a full-bleed card that loses its pixel measurement still
gets graded from the model's own eye, same as any other refusal here;
a case silently read as the card produces a confident, wrong number
that drags the whole grade down with it.
"""
w, h = img_size
left, top, right, bottom = box
margins = (left / w, top / h, (w - right) / w, (h - bottom) / h)
# Under 2.5% of that dimension, on at least two of the four sides.
if sum(1 for m in margins if m < 0.025) >= 2:
return ("this photo's card fills nearly the entire frame with no "
"usable margin around it, so the measurement bands — a few "
"percent of the card's own edge, where real wear actually "
"lives — would land on whatever's in that margin rather "
"than the card. Either the card was shot genuinely "
"edge-to-edge, or something around it (a case, a slab, a "
"holder) filled the frame instead. Reshoot with visible "
"background/mat around the card on all sides for a "
"measurement that means anything.")
return None
def framing_warning(image_bytes):
"""The margin problem described in _box_margin_reason, or None.
Public because the CROPS have the same exposure to it as the pixel
measurements do, and worse consequences. When box detection can't find
the card's real boundary, every crop is cut relative to the wrong
rectangle — a "TOP-LEFT CORNER" close-up of a cased card shows the
CASE's corner bracket, with the card's actual corner off to one side.
The measurements at least refuse and say why; the crops keep being
produced and keep being captioned authoritatively, and the grading
prompt tells the model to judge corners/edges/surface *from* them. So
the caller needs to be able to warn about the framing rather than
silently pass off plastic as cardstock.
"""
if Image is None:
return None
try:
img = Image.open(io.BytesIO(image_bytes))
img.load()
box = _detect_card_box(img) or (0, 0, img.width, img.height)
return _box_margin_reason(box, img.size)
except Exception:
return None
def _surface_map(piece):
"""A band-pass view that isolates surface texture from the artwork.
Scratches, print lines and dents are low-contrast marks sitting on
artwork that is far higher contrast than they are — which is exactly why
they vanish in a normal view. Subtracting a heavily blurred copy cancels
the smooth artwork; subtracting from a lightly blurred copy rather than
the raw pixels first drops the halftone dots, which otherwise dominate
the result on any printed card. What survives is the band where surface
damage lives, and stretching the contrast makes it legible.
The output deliberately exaggerates: holo texture, foil patterns and JPEG
blocking all light up alongside real damage, so it is only ever shown
beside the untouched crop for comparison, never on its own.
"""
grey = piece.convert("L")
fine = grey.filter(ImageFilter.GaussianBlur(SURFACE_FINE_RADIUS))
coarse = grey.filter(ImageFilter.GaussianBlur(SURFACE_COARSE_RADIUS))
band = ImageChops.difference(fine, coarse)
return ImageOps.autocontrast(band, cutoff=SURFACE_AUTOCONTRAST_CUTOFF)
def _band_stats(luma_px, hsv_px, x, y0, y1):
"""Mean luma and saturation down one column of a band.
Luma rather than HSV's "value": V is max(R,G,B), which makes a saturated
yellow border and bare white paper both read as 255 — the exact case this
is trying to measure. Luma weights the channels the way brightness is
actually perceived, so yellow lands near 212 and white at 255, leaving a
real difference to detect.
"""
n = 0
l_total = 0
s_total = 0
for y in range(y0, y1):
l_total += luma_px[x, y]
s_total += hsv_px[x, y][1]
n += 1
if not n:
return None, None
return l_total / float(n), s_total / float(n)
def _column_reference(luma_px, hsv_px, x, y0, y1):
"""Median luma/saturation down a column, plus how much it varies.
Median rather than mean so a few pixels of text don't drag the baseline,
and the spread is returned so the caller can throw the column out
entirely when the reference clearly isn't uniform border.
"""
lumas = []
sats = []
for y in range(y0, y1):
lumas.append(luma_px[x, y])
sats.append(hsv_px[x, y][1])
if not lumas:
return None, None, None
lumas.sort()
sats.sort()
n = len(lumas)
spread = lumas[int(n * 0.9)] - lumas[int(n * 0.1)]
return lumas[n // 2], sats[n // 2], spread
def _measure_one_edge(card, geometry):
"""Whitening measurement for the TOP edge of whatever is passed in.
Each of the four edges is rotated to the top before calling this, so the
logic only ever has to handle one orientation.
The measurement is a local, column-by-column comparison: the outermost
sliver of border is compared against the same border slightly deeper in,
at the same x. Whitening is the paper core showing through, so the outer
band goes lighter and loses saturation relative to the reference — while
a lighting gradient, a coloured border, or a dark card all affect both
bands together and cancel out. That self-referencing is the point: it
needs no idea what the card is supposed to look like.
"""
inset, band, gap = geometry
w, h = card.size
if h < inset + gap + band * 2 or w < 20:
return None
luma = card.convert("L").load()
hsv = card.convert("HSV").load()
edge_y = (inset, inset + band)
# Corners have their own category and their own rounding, so leave them
# out — otherwise every card's four rounded corners inflate every edge.
margin = max(2, int(w * 0.05))
# Read the outermost band once per column. Comparing against a band
# further into the card was tried first and cannot work generally: a
# Pokemon border is barely ten pixels deep, so any reference deep enough
# to be separate lands on the copyright line or the artwork, and a bright
# border measured against dark text reads as whitening down the entire
# edge. The baseline instead comes from the card's own border, worked out
# by the caller across all four edges at once.
# Also read a band just INSIDE the edge band, per column. Whitening is
# confined to the outermost millimetre or two at the cut, so it shows up
# as a step between these two bands. Glare, a glossy sleeve catching the
# light, or simply one side of the photo being brighter lifts BOTH bands
# together and produces no step — which is how the two get told apart.
inner_y = (edge_y[1] + max(1, band // 4), edge_y[1] + max(1, band // 4) + band)
if inner_y[1] > h:
inner_y = None
columns = []
for x in range(margin, w - margin):
l_edge, s_edge = _band_stats(luma, hsv, x, *edge_y)
if l_edge is None:
continue
if inner_y is None:
columns.append((l_edge, s_edge, None, None, None))
continue
l_in, s_in, spread = _column_reference(luma, hsv, x, *inner_y)
columns.append((l_edge, s_edge, l_in, s_in, spread))
if len(columns) < 20:
return None
return columns
def _score_edge(columns, base_l, base_s):
"""Share of an edge's columns that read as whitened.
A column has to clear two independent tests. The first compares it with
the card's own border pooled across all four edges, which catches wear
wherever it sits. The second requires an actual step between the
outermost band and the band just inside it, which is what makes it wear
rather than lighting: a bright reflection raises both bands equally and
fails this test, while paper showing through at the cut does not.
"""
whitened = 0
deltas = []
counted = 0
for l_edge, s_edge, l_in, s_in, spread in columns:
d_light = l_edge - base_l
d_desat = base_s - s_edge
pooled = d_desat >= 55 or d_light >= 28 or (d_light >= 10 and d_desat >= 20)
if l_in is None:
local = True # no inner band available; fall back to pooled only
elif spread is not None and spread > 55:
# The inner band landed on text or artwork, so it cannot confirm
# anything. Skip the column rather than guess — a false "clean" is
# cheaper here than a false accusation of wear.
continue
else:
local = (l_edge - l_in) >= 8 or (s_in - s_edge) >= 18
counted += 1
if pooled and local:
whitened += 1
deltas.append(max(d_light, d_desat * 0.4))
if counted < 20:
return None
return {
"percent": round(100.0 * whitened / counted, 1),
"mean_lift": round(sum(deltas) / float(len(deltas)), 1) if deltas else 0.0,
"columns_used": counted,
"baseline_luma": round(base_l, 1),
"baseline_saturation": round(base_s, 1),
}
def edge_wear_profile(image_bytes):
"""Measure whitening along all four edges. Returns None if unavailable.
Gives back, per edge, the share of its length that reads as whitened and
how strong the lift is — numbers a vision model cannot produce by eye,
and which are immune to the thing that kept defeating it: telling a
genuine pale band apart from the cut line and the border's own
anti-aliasing.
An individual edge can come back None with an entry in edge_notes rather
than a score, when that edge's own material reads as fundamentally
different from the rest of the card's border (a die-cut clear window, a
foil accent strip on one side only) — see the outlier detection below for
why scoring it against the other edges' baseline would be actively wrong,
not just imprecise.
"""
if Image is None:
return None
try:
img = Image.open(io.BytesIO(image_bytes))
img.load()
if img.mode not in ("RGB", "L"):
img = img.convert("RGB")
box = _detect_card_box(img) or (0, 0, img.width, img.height)
margin_reason = _box_margin_reason(box, img.size)
card = img.crop(box).convert("RGB")
short = min(card.size)
geometry = (
# Only just enough to clear the cut line and its anti-aliasing.
# Whitening starts AT the cut, so an inset chosen to be safe
# against background bleed instead steps straight over the thing
# being measured — that alone was halving the signal.
max(2, int(round(short * 0.004))),
max(4, int(round(short * 0.011))), # band thickness
max(8, int(round(short * 0.020))), # depth of the reference band
)
rotations = {
"top": 0,
"right": 90, # rotating 90 CCW brings the right edge to the top
"bottom": 180,
"left": 270,
}
collected = {}
for name, angle in rotations.items():
face = card if angle == 0 else card.rotate(angle, expand=True)
collected[name] = _measure_one_edge(face, geometry)
if all(v is None for v in collected.values()):
return None
# Before pooling, catch an edge whose FINISH differs from the rest of
# the card outright — a die-cut window of clear acetate, a foil
# accent strip on one side only — rather than one that's simply worn.
# This is different from the whole-card foil/silver check below: that
# one catches a card that's uniformly pale everywhere, but a die-cut
# insert is normal printed border on three sides and something else
# entirely on the fourth, so the whole-card check never trips — the
# three normal edges keep the pooled baseline looking sane, which is
# exactly what then makes the fourth edge look catastrophically
# whitened. Wear doesn't produce this: even a badly frayed edge is
# still mostly the same border material with patches of paper
# showing through, so its median luma/saturation barely moves. A
# genuinely different material moves the median far more than
# ordinary wear or lighting ever does.
edge_medians = {}
for name, cols in collected.items():
if not cols:
continue
ls = sorted(c[0] for c in cols)
ss = sorted(c[1] for c in cols)
edge_medians[name] = (ls[len(ls) // 2], ss[len(ss) // 2])
# Two different failure shapes live here, and they need different
# responses. Both start from the same question — do all four edges'
# OWN median readings (one number per side, regardless of how many
# columns that side happened to contribute — see below for why that
# matters) describe one consistent border material?
#
# Deliberately NOT using the quantity-weighted pooled percentiles
# for this check, only the four per-edge medians. A portrait card's
# left/right edges are the long dimension and contribute far more
# columns than top/bottom — found on a real card photographed still
# inside a black display case, where left/right (case material,
# ~1135 columns each) outnumbered top/bottom (~90 columns each) by
# 12-to-1. Pooled by column, the wrong material dominates both the
# 25th and 75th percentile, so the refractor/iqr check below stays
# quiet even though two sides are reading something else entirely.
# Per-edge medians weight all four sides equally regardless of
# their length, which is what actually catches it.
outliers = {}
whole_card_reason = None
if len(edge_medians) == 4:
names = list(edge_medians)
lumas_by_edge = {n: edge_medians[n][0] for n in names}
overall_spread = max(lumas_by_edge.values()) - min(lumas_by_edge.values())
# 60 is comfortably above ordinary lighting/exposure variation
# across a card's four sides (seen in practice: 10-30) and well
# below a genuine different-material gap (150+ for a die-cut
# window or a case's plastic against real cardstock). No
# saturation requirement here, unlike the old version of this
# check: a black case against a white or cream border is a huge
# luma gap with barely any saturation signal at all, since
# neither material has real colour to lose.
if overall_spread >= 60:
# Which single side, if any, explains the whole gap? Try
# excluding each one in turn and keep whichever exclusion
# leaves the tightest remaining trio.
best_excl, best_spread = None, None
for excl in names:
rest = [lumas_by_edge[n] for n in names if n != excl]
spread = max(rest) - min(rest)
if best_spread is None or spread < best_spread:
best_excl, best_spread = excl, spread
if best_spread < 45:
# A single outlier explains it — die-cut window, foil
# accent strip, one side only. Exclude just that side.
outliers[best_excl] = (
"this edge's finish reads as a different material "
"from the card's other edges — likely a clear "
"acetate window, a die-cut insert, or a foil accent "
"on this side only, not whitening. A "
"paper-showing-through measurement doesn't apply to "
"a material that was never opaque to begin with."
)
else:
# No single exclusion brings the rest into agreement —
# the readings split into two genuinely different
# groups (e.g. two sides read one material, two read
# another), so there's no way to tell algorithmically
# which pair is the card's real border. Refuse outright
# rather than guess.
whole_card_reason = (
"the four edges don't read as one consistent border "
"material — some sides measure roughly {:.0f} luma, "
"others roughly {:.0f}, with nothing in between. "
"This usually means the card is still in a case, "
"slab, or protective holder in the photo, so what "
"got measured on some sides is the holder, not the "
"card. Take the card out of anything it's in and "
"reshoot for a measurement that means anything."
).format(min(lumas_by_edge.values()), max(lumas_by_edge.values()))
# Baseline from the non-outlier edges pooled, not each edge against
# itself. Whitening only ever raises luma and lowers saturation, so
# unworn border sits at the low end of one and the high end of the
# other, and quartiles across the pool find it. Scoring an edge
# against only its own length silently fails on the case that
# matters most — an edge worn evenly end to end, where the baseline
# becomes the wear and the damage cancels itself out. Pooling means
# the clean edges anchor the rest — an outlier edge left in this pool
# would drag the baseline toward itself and make the genuinely normal
# edges misread in turn, so it's excluded here.
pooled = [c for name, cols in collected.items()
if cols and name not in outliers for c in cols]
if len(pooled) < 40:
# Nothing survived exclusion (or everything was already sparse) —
# fall back to the full pool rather than giving up outright.
pooled = [c for cols in collected.values() if cols for c in cols]
outliers = {}
if len(pooled) < 40:
return None
lumas = sorted(c[0] for c in pooled)
sats = sorted(c[1] for c in pooled)
base_l = lumas[int(len(lumas) * 0.25)]
base_s = sats[int(len(sats) * 0.75)]
# Decide whether this card can be measured at all before reporting a
# number for it. The method detects paper showing through a printed
# border, which presumes the border is darker and more saturated than
# bare card stock. Silver, white, foil and refractor borders break
# that presumption outright — they are already pale and colourless,
# so ordinary variation in them reads exactly like wear, and the
# result is a confident accusation against a clean card. Refusing to
# answer is the right outcome there; a wrong number is worse than no
# number, because it drags the whole grade down with it.
iqr = lumas[int(len(lumas) * 0.75)] - lumas[int(len(lumas) * 0.25)]
reason = margin_reason or whole_card_reason
if reason is None and base_l >= 205 and base_s <= 45:
reason = ("the card's border is white, silver or foil, where paper "
"showing through looks the same as the border itself")
elif reason is None and iqr >= 70:
reason = ("the border's brightness varies too much across the card "
"— typical of a refractor or prismatic finish — for a "
"whitening measurement to mean anything")
# A whole-card refusal makes every individual edge score meaningless
# too — there's no reliable baseline left to score any of them
# against, not just the sides that triggered it.
edges = {
name: (None if (reason is not None or name in outliers)
else (_score_edge(cols, base_l, base_s) if cols else None))
for name, cols in collected.items()
}
return {
"edges": edges,
"reliable": reason is None,
"reason": reason,
"edge_notes": outliers,
"border_luma": round(base_l, 1),
"border_saturation": round(base_s, 1),
}
except Exception:
return None
def _border_width(px, size, side, border_rgb, skip=0, tolerance=70):
"""How far the uniform border reaches in from one side, in pixels.
Sampled along several lines and taken as the median, so a logo or a bit
of artwork touching the border on one line doesn't decide the answer.
Corners are avoided — their rounding would read as a wider border.
`skip` steps over the cut line and its anti-aliasing before measuring;
without it the very first pixel is still background and every side reads
as a zero-width border.
"""
w, h = size
along = h if side in ("left", "right") else w
depth_limit = int((w if side in ("left", "right") else h) * 0.30)
if depth_limit < 3:
return None
def matches(p):
return (abs(p[0] - border_rgb[0]) + abs(p[1] - border_rgb[1])
+ abs(p[2] - border_rgb[2])) <= tolerance
# Walk inward one row at a time asking how much of that row is still
# border, rather than stopping at the first pixel that isn't. Text
# printed inside the border — a vintage nameplate, a modern copyright
# line, the collector number — otherwise halts the scan almost at the cut
# and reports a border a fraction of its real width. Those characters are
# thin, so the row they sit on is still mostly border; the design proper
# takes the whole row at once, which is the transition being looked for.
positions = [int(along * (0.2 + 0.6 * i / 24.0)) for i in range(25)]
positions = [p for p in positions if 0 <= p < along]
if not positions:
return None
for depth in range(skip, depth_limit):
hits = 0
for pos in positions:
if side == "left":
p = px[depth, pos]
elif side == "right":
p = px[w - 1 - depth, pos]
elif side == "top":
p = px[pos, depth]
else:
p = px[pos, h - 1 - depth]
if matches(p):
hits += 1
if hits < len(positions) * 0.5:
return depth
return depth_limit
def centering_profile(image_bytes):
"""Measure how well centred the card's design is inside its border.
Centering is the one PSA category that is purely geometric — it is a
ratio of border widths, with published tolerances attached — so it can
be measured outright rather than estimated. Returns the widths, the
left/right and top/bottom ratios, and the worse of the two, which is
what a grader keys off.
Returns None when the card has no uniform border to measure against
(full-bleed modern cards) or the read looks implausible, so the caller
can fall back to the model's own eye rather than trust a bad number.
Returns {"reliable": False, "reason": ...} — a refusal with an
explanation, distinct from a silent None — when the four sides read as
DIFFERENT materials from each other. A die-cut card with a clear window
(SPx, E-X Century) is the case that forced this: the side midpoints land
on acetate showing the background through it, the walk inward measures
the widths of two different materials, and the result is a confident
70/30 on a card whose ratio was never measurable — which the model then
treats as authoritative and caps the grade with. Same failure family as
the per-edge outlier check in edge_wear_profile, one measurement over.
"""
if Image is None:
return None
try:
img = Image.open(io.BytesIO(image_bytes))
img.load()
box = _detect_card_box(img) or (0, 0, img.width, img.height)
margin_reason = _box_margin_reason(box, img.size)
card = img.crop(box)
card = card.convert("RGB")
w, h = card.size
if w < 60 or h < 60:
return None
px = card.load()
if margin_reason:
return {"reliable": False, "reason": margin_reason}
# The border colour, sampled just inside the cut at the midpoint of
# each side — far from corners and from any design element. A small
# patch mean per side rather than one pixel, since a single pixel of
# noise shouldn't decide whether the whole measurement runs.
inset = max(2, int(min(w, h) * 0.012))
patch = max(2, int(min(w, h) * 0.008))
def patch_mean(cx, cy):
total = [0, 0, 0]
n = 0
for x in range(max(0, cx - patch), min(w, cx + patch + 1)):
for y in range(max(0, cy - patch), min(h, cy + patch + 1)):
p = px[x, y]
total[0] += p[0]; total[1] += p[1]; total[2] += p[2]
n += 1
return (total[0] // n, total[1] // n, total[2] // n)
side_samples = {
"left": patch_mean(inset, h // 2),
"right": patch_mean(w - 1 - inset, h // 2),
"top": patch_mean(w // 2, inset),
"bottom": patch_mean(w // 2, h - 1 - inset),
}
# All four sides must plausibly be the SAME border before a ratio of
# their widths means anything. Checking each side against the median
# of the other three, one at a time, and stopping at the first hit
# was tried first — and silently mishandled the case that matters
# most: a card still in a black case, where left/right sample the
# case (dark) and top/bottom sample the real card border (bright).
# That's a 2-vs-2 split, not one outlier — comparing "left" against
# the median of {right, top, bottom} lands on a bright value (2 of
# those 3 are bright), so left alone trips the check, the loop
# returns immediately, and "right" — equally case, equally wrong —
# is never even examined, so the message blames one side for a
# problem that's actually on two.
#
# This tries excluding each single side in turn and keeps whichever
# exclusion leaves the other three closest together, which correctly
# tells a real single-outlier case (die-cut window, foil strip —
# excluding it brings the rest into tight agreement) apart from a
# split where no single exclusion works, because two sides are wrong.
names = list(side_samples)
best_excl, best_spread = None, None
for excl in names:
rest = [side_samples[n] for n in names if n != excl]
spread = max(
max(v[i] for v in rest) - min(v[i] for v in rest) for i in range(3))
if best_spread is None or spread < best_spread:
best_excl, best_spread = excl, spread
overall_spread = max(
max(v[i] for v in side_samples.values()) - min(v[i] for v in side_samples.values())
for i in range(3))
if overall_spread > 90:
if best_spread is not None and best_spread <= 40:
return {
"reliable": False,
"reason": ("the {} side's border reads as a completely "
"different colour/material from the other "
"sides — typical of a die-cut or clear-window "
"card, where a border-width ratio doesn't "
"describe centering at all").format(best_excl),
}
return {
"reliable": False,
"reason": ("the four sides don't read as one consistent "
"border — they split into at least two different "
"colours/materials with no single side "
"explaining it. This usually means the card is "
"still in a case, slab, or protective holder in "
"the photo, so some sides measured the holder "
"instead of the card. Take it out and reshoot "
"for a centering measurement that means "
"anything."),
}
samples = list(side_samples.values())
border_rgb = tuple(sorted(c[i] for c in samples)[len(samples) // 2]
for i in range(3))
widths = {side: _border_width(px, (w, h), side, border_rgb, skip=inset)
for side in ("left", "right", "top", "bottom")}
if any(v is None for v in widths.values()):
return None
# A border that vanishes, or that swallows a third of the card, means
# this isn't a bordered card or the sample missed — either way the
# ratio would be meaningless.
if min(widths.values()) < 2:
return None
if max(widths["left"], widths["right"]) > w * 0.28:
return None
if max(widths["top"], widths["bottom"]) > h * 0.28:
return None
def ratio(a, b):
total = float(a + b)
if total <= 0:
return None
bigger = 100.0 * max(a, b) / total
return round(bigger, 1)
horizontal = ratio(widths["left"], widths["right"])
vertical = ratio(widths["top"], widths["bottom"])
if horizontal is None or vertical is None:
return None
return {
"reliable": True,
"widths_px": widths,
"horizontal": horizontal,
"vertical": vertical,
"worst": round(max(horizontal, vertical), 1),
"horizontal_label": "{:.0f}/{:.0f}".format(horizontal, 100 - horizontal),
"vertical_label": "{:.0f}/{:.0f}".format(vertical, 100 - vertical),
"wider_side": ("left" if widths["left"] > widths["right"] else "right",
"top" if widths["top"] > widths["bottom"] else "bottom"),
}
except Exception:
return None
def aspect_profile(image_bytes):
"""Measure the card's own width:height ratio, as a soft signal for trimming.
Unlike centering and edge whitening, this deliberately does NOT gate
itself off with a reliability check the way those do — there isn't one
available. Those two can tell a structurally bad photo apart from a bad
card (a foil border, an angled shot) from pixel evidence alone. This
measurement can't: an axis-aligned bounding box can't distinguish "this
card is genuinely a non-standard shape" from "this card was photographed
slightly rotated in frame", since both inflate the box the same way. That
judgement needs the photo itself, which only the vision model has — so
the number is always returned, and the prompt is the place trimming vs.
photo-angle gets decided, the same way it already decides a print line
from a crease.
Returns the deviation against EVERY standard size, not just the nearest
one. Collapsing to "closest standard" was tried first and measurably
backfired: the three standards sit only 5-11% apart, close enough that a
real few-percent trim on a modern card lands nearer the vintage standard
than its own, and reports as clean. Which standard is actually relevant
depends on the card's era — something only the vision model determines,
from the same photo, after this function has already run — so the
honest fix is hand over all three deviations and let it pick the one
that matches the card it can see, the same division of labour as every
other measurement here.
Only catches UNEVEN trimming — shaving more off one side than another
distorts the ratio. A trim taken symmetrically off all four sides
preserves the ratio while shrinking the whole card, and nothing here can
catch that without a size reference (a ruler, a coin) in the photo.
"""
if Image is None:
return None
try:
img = Image.open(io.BytesIO(image_bytes))
img.load()
box = _detect_card_box(img)
if not box:
return None
bw, bh = box[2] - box[0], box[3] - box[1]
if bw < 40 or bh < 40:
return None
# This measurement IS the detected box, so a box that didn't find
# the card's real boundary doesn't produce an approximate ratio —
# it produces the ratio of something else entirely. On a cased card
# it reported 5.8% off standard, which is the CASE's proportions;
# the UI would then show a trimming hint about a card it never
# measured. Same guard as edge/centering, and more directly
# load-bearing here than for either of those.
margin_reason = _box_margin_reason(box, img.size)
if margin_reason:
return {"reliable": False, "reason": margin_reason}
ratio = min(bw, bh) / float(max(bw, bh))
against_standards = {
name: {
"standard_ratio": round(std_ratio, 4),
"deviation_percent": round(abs(ratio - std_ratio) / std_ratio * 100.0, 1),
}
for name, std_ratio in STANDARD_ASPECT_RATIOS.items()
}
best_name = min(against_standards, key=lambda n: against_standards[n]["deviation_percent"])
return {
"reliable": True,
"measured_ratio": round(ratio, 4),
"width_px": bw,
"height_px": bh,
"against_standards": against_standards,
"best_match": best_name,
}
except Exception:
return None
def _edge_enhanced(strip):
"""Whitening map of an edge strip, keyed on colour saturation.
Edge whitening is physically the paper core showing through a printed
border, so its signature is a loss of saturation rather than a change in
brightness. Mapping saturation and inverting it therefore isolates
exactly the thing being looked for: worn paper lights up, printed colour
goes dark, whatever the border's colour happens to be.
Brightness-based contrast stretching was tried first and is actively
misleading here — on a light border (a yellow Pokemon frame, a white
1980s border) it pushes the border itself to near-white and buries the
very wear it was meant to reveal.
The limit worth knowing: on an already-unsaturated white border there is
no saturation left to lose, so this map stays flat and the untouched
strip beside it has to carry the judgement.
"""
saturation = strip.convert("HSV").split()[1]
return ImageOps.autocontrast(
ImageOps.invert(saturation), cutoff=1).convert("RGB")
def _stack(top, bottom, gap=10):
top = top.convert("RGB")
bottom = bottom.convert("RGB")
w = max(top.width, bottom.width)
canvas = Image.new("RGB", (w, top.height + gap + bottom.height), (18, 18, 18))
canvas.paste(top, (0, 0))
canvas.paste(bottom, (0, top.height + gap))
return canvas
def _side_by_side(left, right, gap=14):
"""Untouched crop beside its surface map, so one can check the other."""
left = left.convert("RGB")
right = right.convert("RGB")
h = max(left.height, right.height)
canvas = Image.new("RGB", (left.width + gap + right.width, h), (18, 18, 18))
canvas.paste(left, (0, 0))
canvas.paste(right, (left.width + gap, 0))
return canvas
def _encode(img):
buffer = io.BytesIO()
img.save(buffer, format="JPEG", quality=92)
return buffer.getvalue()
def _resized(piece, factor):
if factor > 1:
piece = piece.resize(
(max(1, int(piece.width * factor)), max(1, int(piece.height * factor))),
Image.LANCZOS,
)
return piece if piece.mode == "RGB" else piece.convert("RGB")
def _magnify(piece, target_px):
"""Scale a roughly square crop so its long edge hits target_px."""
return _resized(piece, target_px / float(max(piece.size)))
def _magnify_strip(piece):
"""Scale a long thin strip by its short axis, capping the long one.
Targeting the long axis here would be a no-op — it's already large — and
would leave the across-the-strip detail, which is the part that actually
shows whitening, at whatever the source happened to give.
"""
factor = EDGE_SHORT_TARGET_PX / float(min(piece.size))
factor = min(factor, EDGE_LONG_CAP_PX / float(max(piece.size)))
return _resized(piece, factor)
def detail_crops(image_bytes, filename="card.jpg", corners=True, edges=True,
surface=True):
"""Magnified crops of a card's corners and edge strips, in that order.
Returns [(bytes, filename), ...]. Empty when Pillow is missing, the image
is too small to zoom into, or anything goes wrong — grading then proceeds
on the full photo alone, which is the pre-Pillow behaviour.
Corners and edges are cropped separately rather than relying on the
corner crops alone: a corner crop only covers the ends of each side, so
whitening running along the middle of an edge falls between them.
"""
if Image is None:
return []
try:
img = Image.open(io.BytesIO(image_bytes))
img.load()
if img.mode not in ("RGB", "L"):
img = img.convert("RGB")
if max(img.size) < MIN_SOURCE_PX:
return []
box = _detect_card_box(img) or (0, 0, img.width, img.height)
card = img.crop(box)
w, h = card.width, card.height
stem = filename.rsplit(".", 1)[0]
crops = []
if corners:
cw = max(1, int(w * CORNER_FRACTION))
ch = max(1, int(h * CORNER_FRACTION))
regions = {
"top-left": (0, 0, cw, ch),
"top-right": (w - cw, 0, w, ch),
"bottom-left": (0, h - ch, cw, h),
"bottom-right": (w - cw, h - ch, w, h),
}
for name in CORNERS:
piece = card.crop(regions[name])
if min(piece.size) < 8:
continue
crops.append((_encode(_magnify(piece, CORNER_TARGET_PX)),
"{}-{}.jpg".format(stem, name)))
if edges:
# Pull in a couple of pixels first. Box detection lands within
# about two pixels of the cut, and any background left in the
# strip is a problem specifically for the saturation map: an
# unsaturated backdrop (a dark mat, a white desk) reads as bright
# there, sitting exactly where whitening would be and faking it
# on a perfectly clean card. Costs a sliver of the real edge,
# which is worth it to kill a false positive.
inset = max(4, int(round(min(w, h) * 0.012)))
face = card.crop((inset, inset, max(inset + 1, w - inset),
max(inset + 1, h - inset)))
fw, fh = face.size
ew = max(1, int(fw * EDGE_FRACTION))
eh = max(1, int(fh * EDGE_FRACTION))
regions = {
"top-edge": (0, 0, fw, eh),
"right-edge": (fw - ew, 0, fw, fh),
"bottom-edge": (0, fh - eh, fw, fh),
"left-edge": (0, 0, ew, fh),
}
card_for_edges = face
for name in EDGES:
piece = card_for_edges.crop(regions[name])
if min(piece.size) < 8:
continue
# Deliberately the plain strip, with no processed companion.
# A saturation map was tried here (worn paper is desaturated,
# so in principle whitening should light up) and measurably
# backfired: every card, clean ones included, then came back
# "minor whitening", with the location moving between runs.
# The cut line itself and the border's own anti-aliasing
# produce a signal indistinguishable from light wear, so the
# map added noise the model anchored on rather than evidence.
# Surface keeps its processed companion because there the
# signal is genuinely separable; edges do better without one.
crops.append((_encode(_magnify_strip(piece)),
"{}-{}.jpg".format(stem, name)))
if surface:
rows, cols = SURFACE_TILES
index = 0
for ry in range(rows):
for cx in range(cols):
piece = card.crop((
int(w * cx / cols), int(h * ry / rows),
int(w * (cx + 1) / cols), int(h * (ry + 1) / rows),
))
if min(piece.size) < 16:
index += 1
continue
combo = _side_by_side(piece, _surface_map(piece))
name = SURFACE_QUADRANTS[index] if index < len(SURFACE_QUADRANTS) \
else "region{}".format(index)
crops.append((_encode(_magnify(combo, SURFACE_TILE_TARGET_PX)),
"{}-surface-{}.jpg".format(stem, name)))
index += 1
return crops
except Exception:
# A grading run is worth more than a perfect crop — never let an
# image-processing failure take out the whole request.
return []
def corner_crops(image_bytes, filename="card.jpg"):
"""Corner close-ups only. Kept for callers that don't want edge strips."""
return detail_crops(image_bytes, filename, corners=True, edges=False)