diff --git a/cardimage.py b/cardimage.py index af09b51..397c002 100644 --- a/cardimage.py +++ b/cardimage.py @@ -241,6 +241,52 @@ def _detect_card_box(img): return 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. + + Exists because of a real failure: a card photographed still inside a + black display case, where the case filled the whole frame with no + visible background anywhere. _detect_card_box's background-from-corners + approach has nothing to key off in that situation, and it isn't obvious + from the box alone — it returned (0, 23, 985, 1327) on a 986x1346 + photo, which LOOKS like a plausible tight crop, not an obvious failure + like the too-small-area case already guarded against above. But at + essentially zero margin, every downstream measurement's outer/inner + sampling bands — a few percent of the card's own short dimension, by + design, since real wear lives in the outermost sliver — land entirely + inside whatever surrounds the true card, never reaching it. On that + photo they read the case's own embossed texture as "whitening": no + single edge disagreed with the others (the case is uniformly dark on + all four sides), so the per-edge consistency checks elsewhere in this + file never saw a reason to object. + + Two real situations produce a near-zero margin, and there's no reliable + way to tell them apart from pixels alone: a card genuinely photographed + edge-to-edge with nothing but card in frame, or something surrounding + the card being read as part of it. Refusing both is the safer + direction — a full-bleed card that loses its pixel measurement still + gets graded from the model's own eye, same as any other refusal here; + a case silently read as the card produces a confident, wrong number + that drags the whole grade down with it. + """ + w, h = img_size + left, top, right, bottom = box + margins = (left / w, top / h, (w - right) / w, (h - bottom) / h) + # Under 2.5% of that dimension, on at least two of the four sides. + if sum(1 for m in margins if m < 0.025) >= 2: + return ("this photo's card fills nearly the entire frame with no " + "usable margin around it, so the measurement bands — a few " + "percent of the card's own edge, where real wear actually " + "lives — would land on whatever's in that margin rather " + "than the card. Either the card was shot genuinely " + "edge-to-edge, or something around it (a case, a slab, a " + "holder) filled the frame instead. Reshoot with visible " + "background/mat around the card on all sides for a " + "measurement that means anything.") + return None + + def _surface_map(piece): """A band-pass view that isolates surface texture from the artwork. @@ -431,6 +477,7 @@ def edge_wear_profile(image_bytes): if img.mode not in ("RGB", "L"): img = img.convert("RGB") box = _detect_card_box(img) or (0, 0, img.width, img.height) + margin_reason = _box_margin_reason(box, img.size) card = img.crop(box).convert("RGB") short = min(card.size) @@ -479,22 +526,75 @@ def edge_wear_profile(image_bytes): ss = sorted(c[1] for c in cols) edge_medians[name] = (ls[len(ls) // 2], ss[len(ss) // 2]) + # Two different failure shapes live here, and they need different + # responses. Both start from the same question — do all four edges' + # OWN median readings (one number per side, regardless of how many + # columns that side happened to contribute — see below for why that + # matters) describe one consistent border material? + # + # Deliberately NOT using the quantity-weighted pooled percentiles + # for this check, only the four per-edge medians. A portrait card's + # left/right edges are the long dimension and contribute far more + # columns than top/bottom — found on a real card photographed still + # inside a black display case, where left/right (case material, + # ~1135 columns each) outnumbered top/bottom (~90 columns each) by + # 12-to-1. Pooled by column, the wrong material dominates both the + # 25th and 75th percentile, so the refractor/iqr check below stays + # quiet even though two sides are reading something else entirely. + # Per-edge medians weight all four sides equally regardless of + # their length, which is what actually catches it. outliers = {} - for name, (l_med, s_med) in edge_medians.items(): - others = [v for n, v in edge_medians.items() if n != name] - if len(others) < 2: - continue - other_l = sorted(v[0] for v in others)[len(others) // 2] - other_s = sorted(v[1] for v in others)[len(others) // 2] - if (l_med - other_l) >= 45 and (other_s - s_med) >= 35: - outliers[name] = ( - "this edge's finish reads as a different material from " - "the card's other edges (much brighter and less " - "saturated) — likely a clear acetate window, a die-cut " - "insert, or a foil accent on this side only, not " - "whitening. A paper-showing-through measurement doesn't " - "apply to a material that was never opaque to begin with." - ) + whole_card_reason = None + if len(edge_medians) == 4: + names = list(edge_medians) + lumas_by_edge = {n: edge_medians[n][0] for n in names} + overall_spread = max(lumas_by_edge.values()) - min(lumas_by_edge.values()) + # 60 is comfortably above ordinary lighting/exposure variation + # across a card's four sides (seen in practice: 10-30) and well + # below a genuine different-material gap (150+ for a die-cut + # window or a case's plastic against real cardstock). No + # saturation requirement here, unlike the old version of this + # check: a black case against a white or cream border is a huge + # luma gap with barely any saturation signal at all, since + # neither material has real colour to lose. + if overall_spread >= 60: + # Which single side, if any, explains the whole gap? Try + # excluding each one in turn and keep whichever exclusion + # leaves the tightest remaining trio. + best_excl, best_spread = None, None + for excl in names: + rest = [lumas_by_edge[n] for n in names if n != excl] + spread = max(rest) - min(rest) + if best_spread is None or spread < best_spread: + best_excl, best_spread = excl, spread + if best_spread < 45: + # A single outlier explains it — die-cut window, foil + # accent strip, one side only. Exclude just that side. + outliers[best_excl] = ( + "this edge's finish reads as a different material " + "from the card's other edges — likely a clear " + "acetate window, a die-cut insert, or a foil accent " + "on this side only, not whitening. A " + "paper-showing-through measurement doesn't apply to " + "a material that was never opaque to begin with." + ) + else: + # No single exclusion brings the rest into agreement — + # the readings split into two genuinely different + # groups (e.g. two sides read one material, two read + # another), so there's no way to tell algorithmically + # which pair is the card's real border. Refuse outright + # rather than guess. + whole_card_reason = ( + "the four edges don't read as one consistent border " + "material — some sides measure roughly {:.0f} luma, " + "others roughly {:.0f}, with nothing in between. " + "This usually means the card is still in a case, " + "slab, or protective holder in the photo, so what " + "got measured on some sides is the holder, not the " + "card. Take the card out of anything it's in and " + "reshoot for a measurement that means anything." + ).format(min(lumas_by_edge.values()), max(lumas_by_edge.values())) # Baseline from the non-outlier edges pooled, not each edge against # itself. Whitening only ever raises luma and lowers saturation, so @@ -530,17 +630,20 @@ def edge_wear_profile(image_bytes): # 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 = margin_reason or whole_card_reason + if reason is None and 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: + elif reason is None and 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") + # A whole-card refusal makes every individual edge score meaningless + # too — there's no reliable baseline left to score any of them + # against, not just the sides that triggered it. edges = { - name: (None if name in outliers + name: (None if (reason is not None or name in outliers) else (_score_edge(cols, base_l, base_s) if cols else None)) for name, cols in collected.items() } @@ -635,12 +738,16 @@ def centering_profile(image_bytes): try: img = Image.open(io.BytesIO(image_bytes)) img.load() - card = img.crop(_detect_card_box(img) or (0, 0, img.width, img.height)) + box = _detect_card_box(img) or (0, 0, img.width, img.height) + margin_reason = _box_margin_reason(box, img.size) + card = img.crop(box) card = card.convert("RGB") w, h = card.size if w < 60 or h < 60: return None px = card.load() + if margin_reason: + return {"reliable": False, "reason": margin_reason} # The border colour, sampled just inside the cut at the midpoint of # each side — far from corners and from any design element. A small @@ -667,25 +774,57 @@ def centering_profile(image_bytes): } # All four sides must plausibly be the SAME border before a ratio of - # their widths means anything. A side whose colour sits far from the - # median of the other three is a different material — a clear die-cut - # window showing the background, a foil accent strip — and one such - # side invalidates the whole geometric premise, not just its own - # number: the walk inward on every side keys off one shared - # border_rgb. Refuse with the reason rather than return a ratio. - for side, colour in side_samples.items(): - others = [v for s, v in side_samples.items() if s != side] - med = tuple(sorted(o[i] for o in others)[len(others) // 2] - for i in range(3)) - if sum(abs(colour[i] - med[i]) for i in range(3)) > 150: + # their widths means anything. Checking each side against the median + # of the other three, one at a time, and stopping at the first hit + # was tried first — and silently mishandled the case that matters + # most: a card still in a black case, where left/right sample the + # case (dark) and top/bottom sample the real card border (bright). + # That's a 2-vs-2 split, not one outlier — comparing "left" against + # the median of {right, top, bottom} lands on a bright value (2 of + # those 3 are bright), so left alone trips the check, the loop + # returns immediately, and "right" — equally case, equally wrong — + # is never even examined, so the message blames one side for a + # problem that's actually on two. + # + # This tries excluding each single side in turn and keeps whichever + # exclusion leaves the other three closest together, which correctly + # tells a real single-outlier case (die-cut window, foil strip — + # excluding it brings the rest into tight agreement) apart from a + # split where no single exclusion works, because two sides are wrong. + names = list(side_samples) + best_excl, best_spread = None, None + for excl in names: + rest = [side_samples[n] for n in names if n != excl] + spread = max( + max(v[i] for v in rest) - min(v[i] for v in rest) for i in range(3)) + if best_spread is None or spread < best_spread: + best_excl, best_spread = excl, spread + + overall_spread = max( + max(v[i] for v in side_samples.values()) - min(v[i] for v in side_samples.values()) + for i in range(3)) + if overall_spread > 90: + if best_spread is not None and best_spread <= 40: return { "reliable": False, "reason": ("the {} side's border reads as a completely " "different colour/material from the other " "sides — typical of a die-cut or clear-window " "card, where a border-width ratio doesn't " - "describe centering at all").format(side), + "describe centering at all").format(best_excl), } + return { + "reliable": False, + "reason": ("the four sides don't read as one consistent " + "border — they split into at least two different " + "colours/materials with no single side " + "explaining it. This usually means the card is " + "still in a case, slab, or protective holder in " + "the photo, so some sides measured the holder " + "instead of the card. Take it out and reshoot " + "for a centering measurement that means " + "anything."), + } samples = list(side_samples.values()) border_rgb = tuple(sorted(c[i] for c in samples)[len(samples) // 2]