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
|
||||
|
||||
|
||||
# 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):
|
||||
"""None if the detected box leaves a plausible margin on enough sides
|
||||
to trust; otherwise the reason it doesn't.
|
||||
|
|
@ -306,7 +441,8 @@ def framing_warning(image_bytes):
|
|||
try:
|
||||
img = Image.open(io.BytesIO(image_bytes))
|
||||
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)
|
||||
except Exception:
|
||||
return None
|
||||
|
|
@ -501,8 +637,30 @@ def edge_wear_profile(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)
|
||||
box, refined = detect_card_box(img)
|
||||
box = box or (0, 0, img.width, img.height)
|
||||
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")
|
||||
|
||||
short = min(card.size)
|
||||
|
|
@ -763,7 +921,8 @@ def centering_profile(image_bytes):
|
|||
try:
|
||||
img = Image.open(io.BytesIO(image_bytes))
|
||||
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)
|
||||
card = img.crop(box)
|
||||
card = card.convert("RGB")
|
||||
|
|
@ -933,6 +1092,13 @@ def aspect_profile(image_bytes):
|
|||
try:
|
||||
img = Image.open(io.BytesIO(image_bytes))
|
||||
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)
|
||||
if not box:
|
||||
return None
|
||||
|
|
@ -949,6 +1115,18 @@ def aspect_profile(image_bytes):
|
|||
margin_reason = _box_margin_reason(box, img.size)
|
||||
if 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))
|
||||
|
||||
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:
|
||||
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)
|
||||
w, h = card.width, card.height
|
||||
stem = filename.rsplit(".", 1)[0]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue