Add aspect-ratio measurement as a soft trimming signal, reported against every standard card size

This commit is contained in:
Barely Removable 2026-08-22 11:03:54 -07:00
parent b28305dd25
commit f464faca43
5 changed files with 139 additions and 2 deletions

1
app.py
View file

@ -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"],

View file

@ -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.

View file

@ -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 = `<div class="hint">measured whitening — ${esc(measuredLine)}</div>`;
} else if (key === 'centering' && centeringLine) {
extra = `<div class="hint">${esc(centeringLine)}</div>`;
} else if (key === 'corners' && aspectLine) {
extra = `<div class="hint">${esc(aspectLine)}</div>`;
}
return `<tr>
<td style="text-transform:capitalize">${key}</td>

View file

@ -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:

View file

@ -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,