Initial commit: Card Grader deployed to hippofam.com/cards
PWA card-grading app, deployed behind Nginx Proxy Manager on Unraid with basic auth. Includes CARD_GRADER_BASE_PATH support for running under a sub-path, and Docker/compose config for the Unraid deployment.
This commit is contained in:
commit
c7bd71a3e1
19 changed files with 3618 additions and 0 deletions
802
cardimage.py
Normal file
802
cardimage.py
Normal file
|
|
@ -0,0 +1,802 @@
|
|||
"""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")
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
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
|
||||
|
||||
# Baseline from ALL four 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 whole card 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
|
||||
# three clean edges anchor the fourth.
|
||||
pooled = [c for cols in collected.values() if cols for c in cols]
|
||||
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: (_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,
|
||||
"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 _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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue