Add aspect-ratio measurement as a soft trimming signal, reported against every standard card size
This commit is contained in:
parent
b28305dd25
commit
f464faca43
5 changed files with 139 additions and 2 deletions
1
app.py
1
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"],
|
||||
|
|
|
|||
75
cardimage.py
75
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
16
store.py
16
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:
|
||||
|
|
|
|||
35
vision.py
35
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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue