Detect the card INSIDE a holder, not the holder's outline
_detect_card_box works from 'what isn't background', so on a card shot in a toploader, sleeve or slab it finds the HOLDER — the card is never the outermost non-background thing. Every downstream crop was then cut against plastic instead of cardstock. Per-side edge detection can't disambiguate: a holder's rim is as strong a step as the card's cut, and on a card sitting off-centre in its holder the two land at different insets per side (verified on the Jordan: card's left edge at 75px, top edge at ~40px, holder rim at ~34px). What separates them is that a CARD has known proportions and a holder does not -- so this searches combinations of candidate edges and keeps whichever rectangle best matches a real card. Jordan 7.0% -> 0.3% off standard; Ohtani 5.8% -> 0.4%. Verified visually: both crops are the bare card, holder gone. Conservative by construction -- the inner box must land within 2.5% of a standard ratio AND beat the outer by 2.5 points. A correctly-detected bare card already sits near 0%, so nothing inside it can clear that bar and the refinement declines. Confirmed against six normal-photo variants (tight margin, large margin, dark background, die-cut, near-square): none refined. Two consequences handled rather than papered over: - aspect_profile deliberately keeps using the RAW box. The refinement picks the rectangle that best matches a standard ratio, so measuring that and asking 'is this a standard ratio?' is circular and would clear a genuinely trimmed card. It now refuses on holdered cards instead. - edge_wear_profile refuses on holdered cards even though the card is now located. Whitening is detected as the outermost band reading lighter than the one inside it, and a holder's inner edge sits exactly there: measured 58%/53% 'whitening' on two Jordan edges the model called clean reading the same strips. Centering does NOT refuse -- it compares border widths, which clear plastic doesn't distort (Ohtani now reads 51/49, having previously refused outright).
This commit is contained in:
parent
3510f637ba
commit
22bb14e5d9
1 changed files with 183 additions and 4 deletions
187
cardimage.py
187
cardimage.py
|
|
@ -241,6 +241,141 @@ def _detect_card_box(img):
|
||||||
return box
|
return box
|
||||||
|
|
||||||
|
|
||||||
|
# A holder is only a little larger than the card it holds, so the card's
|
||||||
|
# edge is always within this fraction of the detected box. Searching deeper
|
||||||
|
# would start finding the card's own printed border and inner artwork.
|
||||||
|
_INNER_SEARCH_FRACTION = 0.15
|
||||||
|
# Candidate edges per side. More costs nothing (the combination search is
|
||||||
|
# tiny) but risks admitting weak noise peaks as candidates.
|
||||||
|
_INNER_CANDIDATES_PER_SIDE = 5
|
||||||
|
# The inner box must land this close to a real card's proportions...
|
||||||
|
_INNER_MAX_DEVIATION = 2.5
|
||||||
|
# ...AND beat the outer box by at least this much. Together these are what
|
||||||
|
# keeps a normal, correctly-detected card from being "refined" into its own
|
||||||
|
# artwork: a good outer box already sits near 0% deviation, so nothing
|
||||||
|
# inside it can improve by 2.5 points, and the refinement declines.
|
||||||
|
_INNER_MIN_IMPROVEMENT = 2.5
|
||||||
|
|
||||||
|
|
||||||
|
def _aspect_deviation(w, h):
|
||||||
|
"""Percent off the nearest standard card proportion."""
|
||||||
|
ratio = min(w, h) / float(max(w, h))
|
||||||
|
return min(abs(ratio - s) / s * 100.0 for s in STANDARD_ASPECT_RATIOS.values())
|
||||||
|
|
||||||
|
|
||||||
|
def _edge_candidates(profile, limit):
|
||||||
|
"""Strongest steps in a 1-D mean profile, nearby duplicates suppressed."""
|
||||||
|
grad = sorted(((d, abs(profile[d + 1] - profile[d]))
|
||||||
|
for d in range(len(profile) - 1)),
|
||||||
|
key=lambda t: -t[1])
|
||||||
|
out = []
|
||||||
|
for d, v in grad:
|
||||||
|
if v < 8: # below this is texture, not a boundary
|
||||||
|
break
|
||||||
|
if all(abs(d - o) > 6 for o, _ in out):
|
||||||
|
out.append((d, v))
|
||||||
|
if len(out) >= limit:
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _refine_to_inner_card(img, box):
|
||||||
|
"""Find the card INSIDE a holder. Returns (box, refined?).
|
||||||
|
|
||||||
|
_detect_card_box works from "what isn't background", which on a card
|
||||||
|
photographed inside a toploader, sleeve or slab finds the HOLDER's
|
||||||
|
outline — the card is never the outermost non-background thing. Every
|
||||||
|
downstream crop is then cut against the plastic instead of the card,
|
||||||
|
which is what made a Fleer Ultra Jordan's corners and edges unreadable
|
||||||
|
despite being plainly visible through clear plastic.
|
||||||
|
|
||||||
|
Per-side edge detection alone can't fix it: a holder's own rim is just
|
||||||
|
as strong a step as the card's cut, and on a card sitting off-centre in
|
||||||
|
its holder the two are at different insets on different sides. What
|
||||||
|
disambiguates them is that a CARD has known proportions and a holder
|
||||||
|
does not — so this searches combinations of candidate edges and keeps
|
||||||
|
the rectangle that best matches a real card, rather than trusting any
|
||||||
|
single side. On the Jordan that moved a 7.0%-off box to 0.3%-off.
|
||||||
|
|
||||||
|
Deliberately conservative: it must both land very close to a standard
|
||||||
|
ratio and clearly beat the box it started from, or the original stands.
|
||||||
|
"""
|
||||||
|
if Image is None:
|
||||||
|
return box, False
|
||||||
|
try:
|
||||||
|
crop = img.crop(box).convert("L")
|
||||||
|
w, h = crop.size
|
||||||
|
if w < 60 or h < 60:
|
||||||
|
return box, False
|
||||||
|
px = crop.load()
|
||||||
|
|
||||||
|
# Sample the middle half of each axis: the ends run through the
|
||||||
|
# card's rounded corners and the holder's, which blur the step.
|
||||||
|
def col_mean(x):
|
||||||
|
lo, hi = int(h * 0.25), int(h * 0.75)
|
||||||
|
return sum(px[x, y] for y in range(lo, hi)) / float(hi - lo)
|
||||||
|
|
||||||
|
def row_mean(y):
|
||||||
|
lo, hi = int(w * 0.25), int(w * 0.75)
|
||||||
|
return sum(px[x, y] for x in range(lo, hi)) / float(hi - lo)
|
||||||
|
|
||||||
|
nx = max(4, int(w * _INNER_SEARCH_FRACTION))
|
||||||
|
ny = max(4, int(h * _INNER_SEARCH_FRACTION))
|
||||||
|
per = _INNER_CANDIDATES_PER_SIDE
|
||||||
|
# Zero is always a candidate: a side may need no adjustment at all.
|
||||||
|
cl = _edge_candidates([col_mean(d) for d in range(nx)], per) + [(0, 0)]
|
||||||
|
cr = _edge_candidates([col_mean(w - 1 - d) for d in range(nx)], per) + [(0, 0)]
|
||||||
|
ct = _edge_candidates([row_mean(d) for d in range(ny)], per) + [(0, 0)]
|
||||||
|
cb = _edge_candidates([row_mean(h - 1 - d) for d in range(ny)], per) + [(0, 0)]
|
||||||
|
|
||||||
|
outer_dev = _aspect_deviation(w, h)
|
||||||
|
best = None
|
||||||
|
for l, vl in cl:
|
||||||
|
for r, vr in cr:
|
||||||
|
for t, vt in ct:
|
||||||
|
for b, vb in cb:
|
||||||
|
iw, ih = w - l - r, h - t - b
|
||||||
|
# A card fills most of its holder; a big shrink means
|
||||||
|
# this latched onto artwork, not a cut line.
|
||||||
|
if iw < w * 0.7 or ih < h * 0.7:
|
||||||
|
continue
|
||||||
|
dev = _aspect_deviation(iw, ih)
|
||||||
|
strength = (vl + vr + vt + vb) / 4.0
|
||||||
|
# Aspect dominates; edge strength only breaks ties
|
||||||
|
# between rectangles that fit a card equally well.
|
||||||
|
score = dev - strength * 0.05
|
||||||
|
if best is None or score < best[0]:
|
||||||
|
best = (score, dev, (l, t, r, b))
|
||||||
|
if best is None:
|
||||||
|
return box, False
|
||||||
|
|
||||||
|
_, dev, (l, t, r, b) = best
|
||||||
|
if dev > _INNER_MAX_DEVIATION:
|
||||||
|
return box, False
|
||||||
|
if outer_dev - dev < _INNER_MIN_IMPROVEMENT:
|
||||||
|
return box, False
|
||||||
|
if not (l or t or r or b):
|
||||||
|
return box, False
|
||||||
|
return (box[0] + l, box[1] + t, box[2] - r, box[3] - b), True
|
||||||
|
except Exception:
|
||||||
|
return box, False
|
||||||
|
|
||||||
|
|
||||||
|
def detect_card_box(img):
|
||||||
|
"""(box, refined?) — the card's own outline where that can be found.
|
||||||
|
|
||||||
|
`refined` is True when the box had to be pulled in from a surrounding
|
||||||
|
holder. Callers that measure the card's SHAPE need to know: the
|
||||||
|
refinement picks the rectangle closest to a standard card ratio, so
|
||||||
|
asking it afterwards whether the card has a standard ratio is circular
|
||||||
|
and would always answer yes. See aspect_profile.
|
||||||
|
"""
|
||||||
|
box = _detect_card_box(img)
|
||||||
|
if not box:
|
||||||
|
return None, False
|
||||||
|
return _refine_to_inner_card(img, box)
|
||||||
|
|
||||||
|
|
||||||
def _box_margin_reason(box, img_size):
|
def _box_margin_reason(box, img_size):
|
||||||
"""None if the detected box leaves a plausible margin on enough sides
|
"""None if the detected box leaves a plausible margin on enough sides
|
||||||
to trust; otherwise the reason it doesn't.
|
to trust; otherwise the reason it doesn't.
|
||||||
|
|
@ -306,7 +441,8 @@ def framing_warning(image_bytes):
|
||||||
try:
|
try:
|
||||||
img = Image.open(io.BytesIO(image_bytes))
|
img = Image.open(io.BytesIO(image_bytes))
|
||||||
img.load()
|
img.load()
|
||||||
box = _detect_card_box(img) or (0, 0, img.width, img.height)
|
box, _refined = detect_card_box(img)
|
||||||
|
box = box or (0, 0, img.width, img.height)
|
||||||
return _box_margin_reason(box, img.size)
|
return _box_margin_reason(box, img.size)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
@ -501,8 +637,30 @@ def edge_wear_profile(image_bytes):
|
||||||
img.load()
|
img.load()
|
||||||
if img.mode not in ("RGB", "L"):
|
if img.mode not in ("RGB", "L"):
|
||||||
img = img.convert("RGB")
|
img = img.convert("RGB")
|
||||||
box = _detect_card_box(img) or (0, 0, img.width, img.height)
|
box, refined = detect_card_box(img)
|
||||||
|
box = box or (0, 0, img.width, img.height)
|
||||||
margin_reason = _box_margin_reason(box, img.size)
|
margin_reason = _box_margin_reason(box, img.size)
|
||||||
|
# A holder defeats this particular measurement even once the card
|
||||||
|
# itself has been located. Whitening is detected as the outermost
|
||||||
|
# band reading lighter and less saturated than the band just inside
|
||||||
|
# it — and a toploader's inner edge sits exactly there, adding its
|
||||||
|
# own reflection to the outer band on a perfectly clean card.
|
||||||
|
# Measured on a Fleer Ultra Jordan in a toploader: 58% and 53%
|
||||||
|
# "whitening" on two edges the model, reading the same strips by
|
||||||
|
# eye, called clean.
|
||||||
|
# Centering deliberately does NOT refuse here — it compares border
|
||||||
|
# WIDTHS, which is geometry that clear plastic doesn't distort, so
|
||||||
|
# it stays measurable through a holder.
|
||||||
|
if refined and not margin_reason:
|
||||||
|
margin_reason = (
|
||||||
|
"the card is inside a holder, sleeve or slab. Its cut edges "
|
||||||
|
"were located, but a whitening measurement reads the very "
|
||||||
|
"outermost sliver of the card — where the holder's own inner "
|
||||||
|
"edge and its reflections sit — so the number would describe "
|
||||||
|
"the plastic as much as the card. Judge the edges from the "
|
||||||
|
"strip images instead: cut lines and whitening are perfectly "
|
||||||
|
"visible through clear plastic even though they can't be "
|
||||||
|
"measured through it")
|
||||||
card = img.crop(box).convert("RGB")
|
card = img.crop(box).convert("RGB")
|
||||||
|
|
||||||
short = min(card.size)
|
short = min(card.size)
|
||||||
|
|
@ -763,7 +921,8 @@ def centering_profile(image_bytes):
|
||||||
try:
|
try:
|
||||||
img = Image.open(io.BytesIO(image_bytes))
|
img = Image.open(io.BytesIO(image_bytes))
|
||||||
img.load()
|
img.load()
|
||||||
box = _detect_card_box(img) or (0, 0, img.width, img.height)
|
box, _refined = detect_card_box(img)
|
||||||
|
box = box or (0, 0, img.width, img.height)
|
||||||
margin_reason = _box_margin_reason(box, img.size)
|
margin_reason = _box_margin_reason(box, img.size)
|
||||||
card = img.crop(box)
|
card = img.crop(box)
|
||||||
card = card.convert("RGB")
|
card = card.convert("RGB")
|
||||||
|
|
@ -933,6 +1092,13 @@ def aspect_profile(image_bytes):
|
||||||
try:
|
try:
|
||||||
img = Image.open(io.BytesIO(image_bytes))
|
img = Image.open(io.BytesIO(image_bytes))
|
||||||
img.load()
|
img.load()
|
||||||
|
# Deliberately the RAW box, not the refined one every other caller
|
||||||
|
# uses. _refine_to_inner_card picks whichever candidate rectangle
|
||||||
|
# best matches a standard card ratio — so measuring that rectangle's
|
||||||
|
# ratio and asking "is this a standard card ratio?" is circular, and
|
||||||
|
# would answer yes on a genuinely trimmed card. Trimming detection
|
||||||
|
# only means anything against a boundary found without reference to
|
||||||
|
# the answer.
|
||||||
box = _detect_card_box(img)
|
box = _detect_card_box(img)
|
||||||
if not box:
|
if not box:
|
||||||
return None
|
return None
|
||||||
|
|
@ -949,6 +1115,18 @@ def aspect_profile(image_bytes):
|
||||||
margin_reason = _box_margin_reason(box, img.size)
|
margin_reason = _box_margin_reason(box, img.size)
|
||||||
if margin_reason:
|
if margin_reason:
|
||||||
return {"reliable": False, "reason": margin_reason}
|
return {"reliable": False, "reason": margin_reason}
|
||||||
|
# A card sitting inside a holder is the other way this box can be
|
||||||
|
# the wrong rectangle — the margin check won't catch it when the
|
||||||
|
# holder itself is well framed. Refuse rather than report the
|
||||||
|
# holder's proportions as the card's.
|
||||||
|
if _refine_to_inner_card(img, box)[1]:
|
||||||
|
return {
|
||||||
|
"reliable": False,
|
||||||
|
"reason": ("the card appears to be inside a holder, sleeve or "
|
||||||
|
"slab, so the outline measured here is the "
|
||||||
|
"holder's rather than the card's. Its proportions "
|
||||||
|
"say nothing about whether the card was trimmed"),
|
||||||
|
}
|
||||||
ratio = min(bw, bh) / float(max(bw, bh))
|
ratio = min(bw, bh) / float(max(bw, bh))
|
||||||
|
|
||||||
against_standards = {
|
against_standards = {
|
||||||
|
|
@ -1071,7 +1249,8 @@ def detail_crops(image_bytes, filename="card.jpg", corners=True, edges=True,
|
||||||
if max(img.size) < MIN_SOURCE_PX:
|
if max(img.size) < MIN_SOURCE_PX:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
box = _detect_card_box(img) or (0, 0, img.width, img.height)
|
box, _refined = detect_card_box(img)
|
||||||
|
box = box or (0, 0, img.width, img.height)
|
||||||
card = img.crop(box)
|
card = img.crop(box)
|
||||||
w, h = card.width, card.height
|
w, h = card.width, card.height
|
||||||
stem = filename.rsplit(".", 1)[0]
|
stem = filename.rsplit(".", 1)[0]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue