A card photographed still inside a black display case exposed this: the case filled the entire frame with no background anywhere, so _detect_card_box returned a box at essentially zero margin from the photo's own edges (0px left, 1px right on a 986px-wide photo). Every downstream measurement band -- a few percent of the card's own short dimension, by design, since real wear lives in the outermost sliver -- then sampled entirely within the case's embossed plastic texture, never reaching the actual card. That texture read as 98.9% edge whitening on two sides. The per-edge material-consistency check added for the earlier die-cut fix didn't catch this: the case is uniformly dark on all four sides at the shallow sampling depth used, so no edge disagreed with the others -- the false signal came from local texture noise within one uniform (wrong) material, not a mismatch between materials. This needed a different, earlier check: whether box detection could plausibly have found the real card boundary at all, gated on margin as a fraction of the photo before any per-edge analysis runs. Also fixed a related bug in centering_profile's existing die-cut check: it compared each side against the median of the other three and returned on the FIRST hit, so a genuine 2-vs-2 split (both left and right reading the case, both top and bottom reading the real card) blamed a single side and never even examined whether the second was equally wrong. Replaced with a best-single-exclusion search that correctly distinguishes a true one-side outlier from an unexplainable split. Verified against the actual photo that exposed this (card in a scalloped black case) -- both now correctly refuse instead of measuring the case. Regression-tested: normal photos with reasonable margin, and the earlier die-cut single-outlier case, are both unaffected.
1126 lines
51 KiB
Python
1126 lines
51 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 _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
|
|
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)
|