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,