From 22bb14e5d98dadfff697a599095e01cdb9a4f7ee Mon Sep 17 00:00:00 2001 From: Barely Removable Date: Tue, 25 Aug 2026 08:42:53 -0700 Subject: [PATCH] Detect the card INSIDE a holder, not the holder's outline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _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). --- cardimage.py | 187 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 183 insertions(+), 4 deletions(-) diff --git a/cardimage.py b/cardimage.py index 6b3fc36..c76f4f3 100644 --- a/cardimage.py +++ b/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]