The pooled baseline that scores edge whitening was global across all four edges, so a card that's normal printed border on three sides and clear acetate on the fourth had the three normal edges anchor a baseline that made the clear side read as extreme whitening -- correctly detecting a real pixel difference, just the wrong one. Now excludes an edge from both the pool and its own scoring when its median brightness/saturation reads as a fundamentally different material, and surfaces why in the prompt instead of silently dropping it.
935 lines
40 KiB
Python
935 lines
40 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 _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)
|
|
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])
|
|
|
|
outliers = {}
|
|
for name, (l_med, s_med) in edge_medians.items():
|
|
others = [v for n, v in edge_medians.items() if n != name]
|
|
if len(others) < 2:
|
|
continue
|
|
other_l = sorted(v[0] for v in others)[len(others) // 2]
|
|
other_s = sorted(v[1] for v in others)[len(others) // 2]
|
|
if (l_med - other_l) >= 45 and (other_s - s_med) >= 35:
|
|
outliers[name] = (
|
|
"this edge's finish reads as a different material from "
|
|
"the card's other edges (much brighter and less "
|
|
"saturated) — 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."
|
|
)
|
|
|
|
# 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 = None
|
|
if 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 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")
|
|
|
|
edges = {
|
|
name: (None if 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.
|
|
"""
|
|
if Image is None:
|
|
return None
|
|
try:
|
|
img = Image.open(io.BytesIO(image_bytes))
|
|
img.load()
|
|
card = img.crop(_detect_card_box(img) or (0, 0, img.width, img.height))
|
|
card = card.convert("RGB")
|
|
w, h = card.size
|
|
if w < 60 or h < 60:
|
|
return None
|
|
px = card.load()
|
|
|
|
# The border colour, sampled just inside the cut at the midpoint of
|
|
# each side — far from corners and from any design element.
|
|
inset = max(2, int(min(w, h) * 0.012))
|
|
samples = [px[inset, h // 2], px[w - 1 - inset, h // 2],
|
|
px[w // 2, inset], px[w // 2, h - 1 - inset]]
|
|
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 {
|
|
"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
|
|
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 {
|
|
"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)
|