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())