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:
parent
034761e145
commit
d026cce91a
1 changed files with 171 additions and 32 deletions
199
cardimage.py
199
cardimage.py
|
|
@ -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())
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue