diff --git a/cardimage.py b/cardimage.py index 3f4dd9e..af09b51 100644 --- a/cardimage.py +++ b/cardimage.py @@ -619,6 +619,16 @@ def centering_profile(image_bytes): Returns None when the card has no uniform border to measure against (full-bleed modern cards) or the read looks implausible, so the caller can fall back to the model's own eye rather than trust a bad number. + + Returns {"reliable": False, "reason": ...} — a refusal with an + explanation, distinct from a silent None — when the four sides read as + DIFFERENT materials from each other. A die-cut card with a clear window + (SPx, E-X Century) is the case that forced this: the side midpoints land + on acetate showing the background through it, the walk inward measures + the widths of two different materials, and the result is a confident + 70/30 on a card whose ratio was never measurable — which the model then + treats as authoritative and caps the grade with. Same failure family as + the per-edge outlier check in edge_wear_profile, one measurement over. """ if Image is None: return None @@ -633,10 +643,51 @@ def centering_profile(image_bytes): px = card.load() # The border colour, sampled just inside the cut at the midpoint of - # each side — far from corners and from any design element. + # each side — far from corners and from any design element. A small + # patch mean per side rather than one pixel, since a single pixel of + # noise shouldn't decide whether the whole measurement runs. inset = max(2, int(min(w, h) * 0.012)) - samples = [px[inset, h // 2], px[w - 1 - inset, h // 2], - px[w // 2, inset], px[w // 2, h - 1 - inset]] + patch = max(2, int(min(w, h) * 0.008)) + + def patch_mean(cx, cy): + total = [0, 0, 0] + n = 0 + for x in range(max(0, cx - patch), min(w, cx + patch + 1)): + for y in range(max(0, cy - patch), min(h, cy + patch + 1)): + p = px[x, y] + total[0] += p[0]; total[1] += p[1]; total[2] += p[2] + n += 1 + return (total[0] // n, total[1] // n, total[2] // n) + + side_samples = { + "left": patch_mean(inset, h // 2), + "right": patch_mean(w - 1 - inset, h // 2), + "top": patch_mean(w // 2, inset), + "bottom": patch_mean(w // 2, h - 1 - inset), + } + + # 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: + 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), + } + + samples = list(side_samples.values()) border_rgb = tuple(sorted(c[i] for c in samples)[len(samples) // 2] for i in range(3)) @@ -668,6 +719,7 @@ def centering_profile(image_bytes): return None return { + "reliable": True, "widths_px": widths, "horizontal": horizontal, "vertical": vertical, diff --git a/static/app.js b/static/app.js index e74212e..9ea516f 100644 --- a/static/app.js +++ b/static/app.js @@ -137,8 +137,10 @@ function renderGradeBlock(g, opts = {}) { } const cm = g.centering_measurement || null; - const centeringLine = cm - ? `measured — left/right ${cm.horizontal_label} · top/bottom ${cm.vertical_label}` : ''; + const centeringLine = cm && cm.reliable === false + ? `not measurable — ${cm.reason || "this card's cut"}` + : (cm && cm.horizontal_label + ? `measured — left/right ${cm.horizontal_label} · top/bottom ${cm.vertical_label}` : ''); // Only worth surfacing when it's away from noise — most cards sit within // a percent or two of standard and repeating that number on every card diff --git a/vision.py b/vision.py index 9e7c5ab..1d1e8b8 100644 --- a/vision.py +++ b/vision.py @@ -264,6 +264,14 @@ leniently, 75/25 for a 10 and 90/10 from 9 downwards): Note that centering is a manufacturing trait, not damage: a badly centred \ card can still be pristine, and PSA may grade it strongly with an OC \ (off-centre) qualifier rather than a low number. + DIE-CUT and clear-window cards (SPx, E-X Century, jersey- or \ +shape-die-cuts) have no uniform rectangular border, so border-width ratios \ +mostly do not apply to them. Judge centering there as the printed design's \ +registration to the die-cut shape and the card's physical edges — that is \ +what PSA keys off for these issues — and do not reconstruct a border ratio \ +by eye on a card that has no border. If a MEASURED CENTERING block was \ +supplied anyway on such a card, distrust it outright: the measurement \ +assumes a uniform printed border this card does not have. - CORNERS: look for whitening, fraying, softness, or blunting at each of the \ four corners. Sharp corners on all four is 9-10 territory; slight whitening \ visible under magnification but not to the eye is 8-9; obvious whitening or \ @@ -646,7 +654,20 @@ def grade_card(images, api_key=None, model=None, effort=None, zoom_details=True) aspect = cardimage.aspect_profile(images[0][0]) if can_measure else None prompt_parts = [] - if centering: + if centering and centering.get("reliable") is False: + prompt_parts.append( + "CENTERING COULD NOT BE MEASURED on this card, because {}. " + "You are getting no border-width numbers, and on a card like " + "this a border-width ratio is the wrong tool anyway: judge " + "centering by eye as the printed design's alignment relative to " + "the die-cut shape and the card's physical edges — that is what " + "PSA actually keys off for die-cut issues. Do not reconstruct a " + "left/right border ratio visually on a card with no uniform " + "border; describe what you can genuinely see about the cut's " + "registration to the printed pattern, and widen the range if " + "the photo doesn't settle it.".format( + centering.get("reason", "of its cut"))) + elif centering: prompt_parts.append( "MEASURED CENTERING — computed from the pixels by finding the " "border on each side of the card:\n"