From f464faca43495591891de42e1513b04ddddfa70c Mon Sep 17 00:00:00 2001 From: Barely Removable Date: Sat, 22 Aug 2026 11:03:54 -0700 Subject: [PATCH] Add aspect-ratio measurement as a soft trimming signal, reported against every standard card size --- app.py | 1 + cardimage.py | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++ static/app.js | 14 ++++++++++ store.py | 16 +++++++++-- vision.py | 35 ++++++++++++++++++++++++ 5 files changed, 139 insertions(+), 2 deletions(-) diff --git a/app.py b/app.py index cdaae8d..46834d6 100644 --- a/app.py +++ b/app.py @@ -343,6 +343,7 @@ class Handler(BaseHTTPRequestHandler): "card_note": result["card_note"], "edge_measurements": result["edge_measurements"], "centering_measurement": result["centering_measurement"], + "aspect_measurement": result["aspect_measurement"], "estimated_grade": result["estimated_grade"], "grade_low": result["grade_low"], "grade_high": result["grade_high"], diff --git a/cardimage.py b/cardimage.py index 89fb233..718871b 100644 --- a/cardimage.py +++ b/cardimage.py @@ -66,6 +66,17 @@ MIN_SOURCE_PX = 600 CORNERS = ("top-left", "top-right", "bottom-left", "bottom-right") EDGES = ("top-edge", "right-edge", "bottom-edge", "left-edge") +# Short-side/long-side ratios for card stock sizes actually in circulation. +# Trimming is compared against whichever of these is closest, not one fixed +# number — treating every card as one standard size would flag genuinely +# factory-cut cards (a tobacco-era T206, a wide 1930s strip card) as trimmed +# just for being a different shape than a modern card. +STANDARD_ASPECT_RATIOS = { + "modern (2.5\" x 3.5\", most post-1957 issues)": 2.5 / 3.5, + "tobacco-era (roughly 1.5\" x 2.5\", T206 and similar pre-1920s)": 1.5 / 2.5, + "wide vintage (roughly 2.0\" x 3.0\", some 1930s-50s strip/premium issues)": 2.0 / 3.0, +} + def available(): return Image is not None @@ -612,6 +623,70 @@ def centering_profile(image_bytes): return None +def aspect_profile(image_bytes): + """Measure the card's own width:height ratio, as a soft signal for trimming. + + Unlike centering and edge whitening, this deliberately does NOT gate + itself off with a reliability check the way those do — there isn't one + available. Those two can tell a structurally bad photo apart from a bad + card (a foil border, an angled shot) from pixel evidence alone. This + measurement can't: an axis-aligned bounding box can't distinguish "this + card is genuinely a non-standard shape" from "this card was photographed + slightly rotated in frame", since both inflate the box the same way. That + judgement needs the photo itself, which only the vision model has — so + the number is always returned, and the prompt is the place trimming vs. + photo-angle gets decided, the same way it already decides a print line + from a crease. + + Returns the deviation against EVERY standard size, not just the nearest + one. Collapsing to "closest standard" was tried first and measurably + backfired: the three standards sit only 5-11% apart, close enough that a + real few-percent trim on a modern card lands nearer the vintage standard + than its own, and reports as clean. Which standard is actually relevant + depends on the card's era — something only the vision model determines, + from the same photo, after this function has already run — so the + honest fix is hand over all three deviations and let it pick the one + that matches the card it can see, the same division of labour as every + other measurement here. + + Only catches UNEVEN trimming — shaving more off one side than another + distorts the ratio. A trim taken symmetrically off all four sides + preserves the ratio while shrinking the whole card, and nothing here can + catch that without a size reference (a ruler, a coin) in the photo. + """ + if Image is None: + return None + try: + img = Image.open(io.BytesIO(image_bytes)) + img.load() + box = _detect_card_box(img) + if not box: + return None + bw, bh = box[2] - box[0], box[3] - box[1] + if bw < 40 or bh < 40: + return None + ratio = min(bw, bh) / float(max(bw, bh)) + + against_standards = { + name: { + "standard_ratio": round(std_ratio, 4), + "deviation_percent": round(abs(ratio - std_ratio) / std_ratio * 100.0, 1), + } + for name, std_ratio in STANDARD_ASPECT_RATIOS.items() + } + best_name = min(against_standards, key=lambda n: against_standards[n]["deviation_percent"]) + + return { + "measured_ratio": round(ratio, 4), + "width_px": bw, + "height_px": bh, + "against_standards": against_standards, + "best_match": best_name, + } + except Exception: + return None + + def _edge_enhanced(strip): """Whitening map of an edge strip, keyed on colour saturation. diff --git a/static/app.js b/static/app.js index 821b5b9..3dbf6c7 100644 --- a/static/app.js +++ b/static/app.js @@ -140,6 +140,18 @@ function renderGradeBlock(g, opts = {}) { const centeringLine = cm ? `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 + // would just be clutter, not information. Shown against its best-matching + // standard; which standard is actually relevant is a judgment call the + // model made with the photo in hand, not something this line re-derives. + const am = g.aspect_measurement || null; + const bestDev = am && am.against_standards && am.best_match + ? am.against_standards[am.best_match].deviation_percent : null; + const aspectLine = (bestDev !== null && bestDev >= 3) + ? `ratio ${bestDev}% off standard (${am.best_match}) — ` + + `verify this isn't just a rotated photo before reading it as trimming` : ''; + const rows = ['centering', 'corners', 'edges', 'surface'].map((key) => { const cat = (g.categories || {})[key] || {}; const sev = cat.severity || 'cannot_assess'; @@ -148,6 +160,8 @@ function renderGradeBlock(g, opts = {}) { extra = `
measured whitening — ${esc(measuredLine)}
`; } else if (key === 'centering' && centeringLine) { extra = `
${esc(centeringLine)}
`; + } else if (key === 'corners' && aspectLine) { + extra = `
${esc(aspectLine)}
`; } return ` ${key} diff --git a/store.py b/store.py index 81a7c4c..17c8634 100644 --- a/store.py +++ b/store.py @@ -38,6 +38,7 @@ CREATE TABLE IF NOT EXISTS grades ( categories_json TEXT, edge_measurements_json TEXT, centering_measurement_json TEXT, + aspect_measurement_json TEXT, limitations_json TEXT, note TEXT, estimated_cost REAL, @@ -65,6 +66,14 @@ def init(): conn = connect() try: conn.executescript(SCHEMA) + # CREATE TABLE IF NOT EXISTS never touches an already-existing table, + # so a column added after cards were already graded needs its own + # migration — guarded because re-running this against a database + # that already has the column would otherwise error every startup. + try: + conn.execute("ALTER TABLE grades ADD COLUMN aspect_measurement_json TEXT") + except sqlite3.OperationalError: + pass for key, value in DEFAULT_SETTINGS.items(): conn.execute( "INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", @@ -127,8 +136,9 @@ def save_grade(grade, thumbnail=None, label=None): " model, estimated_grade, " " grade_low, grade_high, confidence, categories_json, " " edge_measurements_json, centering_measurement_json, " + " aspect_measurement_json, " " limitations_json, note, estimated_cost, usage_json) " - "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", ( now(), label, grade.get("card_type"), grade.get("card_note"), grade.get("image_count"), thumbnail, @@ -138,6 +148,7 @@ def save_grade(grade, thumbnail=None, label=None): json.dumps(grade.get("categories")), json.dumps(grade.get("edge_measurements")), json.dumps(grade.get("centering_measurement")), + json.dumps(grade.get("aspect_measurement")), json.dumps(grade.get("limitations")), grade.get("note"), grade.get("estimated_cost"), json.dumps(grade.get("usage")), @@ -152,7 +163,8 @@ def save_grade(grade, thumbnail=None, label=None): def _row_to_grade(row): d = dict(row) for key in ("categories_json", "edge_measurements_json", - "centering_measurement_json", "limitations_json", "usage_json"): + "centering_measurement_json", "aspect_measurement_json", + "limitations_json", "usage_json"): out_key = key[:-len("_json")] raw = d.pop(key, None) try: diff --git a/vision.py b/vision.py index 497f11b..d66a255 100644 --- a/vision.py +++ b/vision.py @@ -643,6 +643,7 @@ def grade_card(images, api_key=None, model=None, effort=None, zoom_details=True) can_measure = zoom_details and images and cardimage.available() measured = cardimage.edge_wear_profile(images[0][0]) if can_measure else None centering = cardimage.centering_profile(images[0][0]) if can_measure else None + aspect = cardimage.aspect_profile(images[0][0]) if can_measure else None prompt_parts = [] if centering: @@ -705,6 +706,39 @@ def grade_card(images, api_key=None, model=None, effort=None, zoom_details=True) "strip images to describe what the wear looks like and to " "catch what the measurement does not look for at all, such as " "a nick, a chip, or a crushed edge.") + if aspect: + standard_rows = "\n".join( + " {}: {:.4f} standard → {:.1f}% deviation".format( + name, data["standard_ratio"], data["deviation_percent"]) + for name, data in aspect["against_standards"].items()) + prompt_parts.append( + "MEASURED CARD PROPORTIONS — the width:height ratio of the card's " + "own detected outline in this photo, computed from the pixels: " + "{:.4f}. Compared against every standard card size, since which " + "one is relevant depends on the card's era, which is for you to " + "judge, not this measurement:\n{}\n" + "Use the deviation against whichever standard actually matches " + "the card type and era you're identifying it as — a modern card " + "compared against the tobacco-era ratio, or vice versa, will show " + "a large 'deviation' that means nothing at all.\n" + "Unlike the centering and edge numbers above, this one comes with " + "NO reliability check already applied, because the code cannot " + "tell a genuinely non-standard card apart from one simply " + "photographed at a slight rotation — both inflate the measured " + "box the same way. That call needs the photo, which only you " + "have: look at whether the card actually sits square in the " + "frame before trusting this number at all. A deviation under " + "roughly 3% against the RELEVANT standard is normal photo-crop " + "noise and means nothing either way. A larger one on a photo that " + "looks square-on is worth connecting to the corner-squareness " + "guidance above — it can corroborate a miscut or trimming " + "suspicion you already have visual grounds for, but it should " + "never be the ONLY reason you raise one; a rotated or " + "slightly-cropped photo produces exactly the same number on a " + "perfectly normal card. This only catches UNEVEN trimming (more " + "removed from one side than another) — it cannot see a symmetric " + "trim taken off all four sides evenly.".format( + aspect["measured_ratio"], standard_rows)) if crops: prompt_parts.append( "The close-ups above are upscaled crops of the full card photo, each " @@ -734,6 +768,7 @@ def grade_card(images, api_key=None, model=None, effort=None, zoom_details=True) "card_note": (parsed.get("card_note") or "").strip(), "edge_measurements": measured, "centering_measurement": centering, + "aspect_measurement": aspect, "estimated_grade": _clean_grade(parsed.get("estimated_grade")), "grade_low": low, "grade_high": high,