From 552f862f4cdc96cf7cfd3ef2a8ae6773ee56b284 Mon Sep 17 00:00:00 2001 From: Barely Removable Date: Sat, 22 Aug 2026 11:09:44 -0700 Subject: [PATCH] Fix false edge-whitening on die-cut/mixed-material cards (e.g. SPx clear acetate window) The pooled baseline that scores edge whitening was global across all four edges, so a card that's normal printed border on three sides and clear acetate on the fourth had the three normal edges anchor a baseline that made the clear side read as extreme whitening -- correctly detecting a real pixel difference, just the wrong one. Now excludes an edge from both the pool and its own scoring when its median brightness/saturation reads as a fundamentally different material, and surfaces why in the prompt instead of silently dropping it. --- cardimage.py | 74 ++++++++++++++++++++++++++++++++++++++++++++++------ vision.py | 18 +++++++++++++ 2 files changed, 84 insertions(+), 8 deletions(-) diff --git a/cardimage.py b/cardimage.py index 718871b..3f4dd9e 100644 --- a/cardimage.py +++ b/cardimage.py @@ -415,6 +415,13 @@ def edge_wear_profile(image_bytes): and which are immune to the thing that kept defeating it: telling a genuine pale band apart from the cut line and the border's own anti-aliasing. + + An individual edge can come back None with an entry in edge_notes rather + than a score, when that edge's own material reads as fundamentally + different from the rest of the card's border (a die-cut clear window, a + foil accent strip on one side only) — see the outlier detection below for + why scoring it against the other edges' baseline would be actively wrong, + not just imprecise. """ if Image is None: return None @@ -450,15 +457,62 @@ def edge_wear_profile(image_bytes): if all(v is None for v in collected.values()): return None - # Baseline from ALL four edges pooled, not each edge against itself. - # Whitening only ever raises luma and lowers saturation, so unworn - # border sits at the low end of one and the high end of the other, - # and quartiles across the whole card find it. Scoring an edge + # Before pooling, catch an edge whose FINISH differs from the rest of + # the card outright — a die-cut window of clear acetate, a foil + # accent strip on one side only — rather than one that's simply worn. + # This is different from the whole-card foil/silver check below: that + # one catches a card that's uniformly pale everywhere, but a die-cut + # insert is normal printed border on three sides and something else + # entirely on the fourth, so the whole-card check never trips — the + # three normal edges keep the pooled baseline looking sane, which is + # exactly what then makes the fourth edge look catastrophically + # whitened. Wear doesn't produce this: even a badly frayed edge is + # still mostly the same border material with patches of paper + # showing through, so its median luma/saturation barely moves. A + # genuinely different material moves the median far more than + # ordinary wear or lighting ever does. + edge_medians = {} + for name, cols in collected.items(): + if not cols: + continue + ls = sorted(c[0] for c in cols) + ss = sorted(c[1] for c in cols) + edge_medians[name] = (ls[len(ls) // 2], ss[len(ss) // 2]) + + 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." + ) + + # Baseline from the non-outlier edges pooled, not each edge against + # itself. Whitening only ever raises luma and lowers saturation, so + # unworn border sits at the low end of one and the high end of the + # other, and quartiles across the pool find it. Scoring an edge # against only its own length silently fails on the case that # matters most — an edge worn evenly end to end, where the baseline # becomes the wear and the damage cancels itself out. Pooling means - # three clean edges anchor the fourth. - pooled = [c for cols in collected.values() if cols for c in cols] + # the clean edges anchor the rest — an outlier edge left in this pool + # would drag the baseline toward itself and make the genuinely normal + # edges misread in turn, so it's excluded here. + pooled = [c for name, cols in collected.items() + if cols and name not in outliers for c in cols] + if len(pooled) < 40: + # Nothing survived exclusion (or everything was already sparse) — + # fall back to the full pool rather than giving up outright. + pooled = [c for cols in collected.values() if cols for c in cols] + outliers = {} if len(pooled) < 40: return None lumas = sorted(c[0] for c in pooled) @@ -485,12 +539,16 @@ def edge_wear_profile(image_bytes): "— typical of a refractor or prismatic finish — for a " "whitening measurement to mean anything") - edges = {name: (_score_edge(cols, base_l, base_s) if cols else None) - for name, cols in collected.items()} + edges = { + name: (None if name in outliers + else (_score_edge(cols, base_l, base_s) if cols else None)) + for name, cols in collected.items() + } return { "edges": edges, "reliable": reason is None, "reason": reason, + "edge_notes": outliers, "border_luma": round(base_l, 1), "border_saturation": round(base_s, 1), } diff --git a/vision.py b/vision.py index d66a255..9e7c5ab 100644 --- a/vision.py +++ b/vision.py @@ -676,11 +676,29 @@ def grade_card(images, api_key=None, model=None, effort=None, zoom_details=True) measured.get("reason", "of its finish"))) elif measured: rows = [] + note_rows = [] + edge_notes = measured.get("edge_notes") or {} for side in ("top", "right", "bottom", "left"): data = (measured.get("edges") or {}).get(side) if data: rows.append(" {} edge: {:.1f}% of its length".format( side, data["percent"])) + elif side in edge_notes: + note_rows.append(" {} edge: NOT MEASURED — {}".format( + side, edge_notes[side])) + if note_rows: + prompt_parts.append( + "\n".join(note_rows) + "\n" + "For any edge noted above as not measured due to a different " + "material, judge it from its strip image alone, on its own " + "terms as whatever that material is — do not read its " + "brightness or lack of saturation as whitening just because " + "it looks nothing like the card's other edges. Look instead " + "for actual physical damage on that material: a crack or " + "chip in a clear acetate window, foil peeling or flaking, a " + "scuff that catches light differently than the surrounding " + "area — the same kind of judgement as any other edge, just " + "without a number to anchor it.") if rows: prompt_parts.append( "MEASURED EDGE WHITENING — computed directly from the pixels, "