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:
Barely Removable 2026-08-25 08:42:53 -07:00
parent 3510f637ba
commit 22bb14e5d9

View file

@ -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]