Refuse edge/centering measurement when box detection found no real margin

A card photographed still inside a black display case exposed this: the
case filled the entire frame with no background anywhere, so
_detect_card_box returned a box at essentially zero margin from the photo's
own edges (0px left, 1px right on a 986px-wide photo). Every downstream
measurement band -- a few percent of the card's own short dimension, by
design, since real wear lives in the outermost sliver -- then sampled
entirely within the case's embossed plastic texture, never reaching the
actual card. That texture read as 98.9% edge whitening on two sides.

The per-edge material-consistency check added for the earlier die-cut fix
didn't catch this: the case is uniformly dark on all four sides at the
shallow sampling depth used, so no edge disagreed with the others -- the
false signal came from local texture noise within one uniform (wrong)
material, not a mismatch between materials. This needed a different,
earlier check: whether box detection could plausibly have found the real
card boundary at all, gated on margin as a fraction of the photo before any
per-edge analysis runs.

Also fixed a related bug in centering_profile's existing die-cut check: it
compared each side against the median of the other three and returned on
the FIRST hit, so a genuine 2-vs-2 split (both left and right reading the
case, both top and bottom reading the real card) blamed a single side and
never even examined whether the second was equally wrong. Replaced with a
best-single-exclusion search that correctly distinguishes a true one-side
outlier from an unexplainable split.

Verified against the actual photo that exposed this (card in a scalloped
black case) -- both now correctly refuse instead of measuring the case.
Regression-tested: normal photos with reasonable margin, and the earlier
die-cut single-outlier case, are both unaffected.
This commit is contained in:
Barely Removable 2026-08-23 08:05:51 -07:00
parent 034761e145
commit d026cce91a

View file

@ -241,6 +241,52 @@ def _detect_card_box(img):
return box 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): def _surface_map(piece):
"""A band-pass view that isolates surface texture from the artwork. """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"): 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 = _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") card = img.crop(box).convert("RGB")
short = min(card.size) short = min(card.size)
@ -479,22 +526,75 @@ def edge_wear_profile(image_bytes):
ss = sorted(c[1] for c in cols) ss = sorted(c[1] for c in cols)
edge_medians[name] = (ls[len(ls) // 2], ss[len(ss) // 2]) 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 = {} outliers = {}
for name, (l_med, s_med) in edge_medians.items(): whole_card_reason = None
others = [v for n, v in edge_medians.items() if n != name] if len(edge_medians) == 4:
if len(others) < 2: names = list(edge_medians)
continue lumas_by_edge = {n: edge_medians[n][0] for n in names}
other_l = sorted(v[0] for v in others)[len(others) // 2] overall_spread = max(lumas_by_edge.values()) - min(lumas_by_edge.values())
other_s = sorted(v[1] for v in others)[len(others) // 2] # 60 is comfortably above ordinary lighting/exposure variation
if (l_med - other_l) >= 45 and (other_s - s_med) >= 35: # across a card's four sides (seen in practice: 10-30) and well
outliers[name] = ( # below a genuine different-material gap (150+ for a die-cut
"this edge's finish reads as a different material from " # window or a case's plastic against real cardstock). No
"the card's other edges (much brighter and less " # saturation requirement here, unlike the old version of this
"saturated) — likely a clear acetate window, a die-cut " # check: a black case against a white or cream border is a huge
"insert, or a foil accent on this side only, not " # luma gap with barely any saturation signal at all, since
"whitening. A paper-showing-through measurement doesn't " # neither material has real colour to lose.
"apply to a material that was never opaque to begin with." 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 # Baseline from the non-outlier edges pooled, not each edge against
# itself. Whitening only ever raises luma and lowers saturation, so # 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 # answer is the right outcome there; a wrong number is worse than no
# number, because it drags the whole grade down with it. # number, because it drags the whole grade down with it.
iqr = lumas[int(len(lumas) * 0.75)] - lumas[int(len(lumas) * 0.25)] iqr = lumas[int(len(lumas) * 0.75)] - lumas[int(len(lumas) * 0.25)]
reason = None reason = margin_reason or whole_card_reason
if base_l >= 205 and base_s <= 45: if reason is None and base_l >= 205 and base_s <= 45:
reason = ("the card's border is white, silver or foil, where paper " reason = ("the card's border is white, silver or foil, where paper "
"showing through looks the same as the border itself") "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 " reason = ("the border's brightness varies too much across the card "
"— typical of a refractor or prismatic finish — for a " "— typical of a refractor or prismatic finish — for a "
"whitening measurement to mean anything") "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 = { 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)) else (_score_edge(cols, base_l, base_s) if cols else None))
for name, cols in collected.items() for name, cols in collected.items()
} }
@ -635,12 +738,16 @@ 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()
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") card = card.convert("RGB")
w, h = card.size w, h = card.size
if w < 60 or h < 60: if w < 60 or h < 60:
return None return None
px = card.load() px = card.load()
if margin_reason:
return {"reliable": False, "reason": margin_reason}
# The border colour, sampled just inside the cut at the midpoint of # The border colour, sampled just inside the cut at the midpoint of
# each side — far from corners and from any design element. A small # each side — far from corners and from any design element. A small
@ -667,24 +774,56 @@ def centering_profile(image_bytes):
} }
# All four sides must plausibly be the SAME border before a ratio of # 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 # their widths means anything. Checking each side against the median
# median of the other three is a different material — a clear die-cut # of the other three, one at a time, and stopping at the first hit
# window showing the background, a foil accent strip — and one such # was tried first — and silently mishandled the case that matters
# side invalidates the whole geometric premise, not just its own # most: a card still in a black case, where left/right sample the
# number: the walk inward on every side keys off one shared # case (dark) and top/bottom sample the real card border (bright).
# border_rgb. Refuse with the reason rather than return a ratio. # That's a 2-vs-2 split, not one outlier — comparing "left" against
for side, colour in side_samples.items(): # the median of {right, top, bottom} lands on a bright value (2 of
others = [v for s, v in side_samples.items() if s != side] # those 3 are bright), so left alone trips the check, the loop
med = tuple(sorted(o[i] for o in others)[len(others) // 2] # 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)) for i in range(3))
if sum(abs(colour[i] - med[i]) for i in range(3)) > 150: if overall_spread > 90:
if best_spread is not None and best_spread <= 40:
return { return {
"reliable": False, "reliable": False,
"reason": ("the {} side's border reads as a completely " "reason": ("the {} side's border reads as a completely "
"different colour/material from the other " "different colour/material from the other "
"sides — typical of a die-cut or clear-window " "sides — typical of a die-cut or clear-window "
"card, where a border-width ratio doesn't " "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()) samples = list(side_samples.values())