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"],
|
"card_note": result["card_note"],
|
||||||
"edge_measurements": result["edge_measurements"],
|
"edge_measurements": result["edge_measurements"],
|
||||||
"centering_measurement": result["centering_measurement"],
|
"centering_measurement": result["centering_measurement"],
|
||||||
|
"aspect_measurement": result["aspect_measurement"],
|
||||||
"estimated_grade": result["estimated_grade"],
|
"estimated_grade": result["estimated_grade"],
|
||||||
"grade_low": result["grade_low"],
|
"grade_low": result["grade_low"],
|
||||||
"grade_high": result["grade_high"],
|
"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")
|
CORNERS = ("top-left", "top-right", "bottom-left", "bottom-right")
|
||||||
EDGES = ("top-edge", "right-edge", "bottom-edge", "left-edge")
|
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():
|
def available():
|
||||||
return Image is not None
|
return Image is not None
|
||||||
|
|
@ -612,6 +623,70 @@ def centering_profile(image_bytes):
|
||||||
return None
|
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):
|
def _edge_enhanced(strip):
|
||||||
"""Whitening map of an edge strip, keyed on colour saturation.
|
"""Whitening map of an edge strip, keyed on colour saturation.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,18 @@ function renderGradeBlock(g, opts = {}) {
|
||||||
const centeringLine = cm
|
const centeringLine = cm
|
||||||
? `measured — left/right ${cm.horizontal_label} · top/bottom ${cm.vertical_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
|
||||||
|
// 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 rows = ['centering', 'corners', 'edges', 'surface'].map((key) => {
|
||||||
const cat = (g.categories || {})[key] || {};
|
const cat = (g.categories || {})[key] || {};
|
||||||
const sev = cat.severity || 'cannot_assess';
|
const sev = cat.severity || 'cannot_assess';
|
||||||
|
|
@ -148,6 +160,8 @@ function renderGradeBlock(g, opts = {}) {
|
||||||
extra = `<div class="hint">measured whitening — ${esc(measuredLine)}</div>`;
|
extra = `<div class="hint">measured whitening — ${esc(measuredLine)}</div>`;
|
||||||
} else if (key === 'centering' && centeringLine) {
|
} else if (key === 'centering' && centeringLine) {
|
||||||
extra = `<div class="hint">${esc(centeringLine)}</div>`;
|
extra = `<div class="hint">${esc(centeringLine)}</div>`;
|
||||||
|
} else if (key === 'corners' && aspectLine) {
|
||||||
|
extra = `<div class="hint">${esc(aspectLine)}</div>`;
|
||||||
}
|
}
|
||||||
return `<tr>
|
return `<tr>
|
||||||
<td style="text-transform:capitalize">${key}</td>
|
<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,
|
categories_json TEXT,
|
||||||
edge_measurements_json TEXT,
|
edge_measurements_json TEXT,
|
||||||
centering_measurement_json TEXT,
|
centering_measurement_json TEXT,
|
||||||
|
aspect_measurement_json TEXT,
|
||||||
limitations_json TEXT,
|
limitations_json TEXT,
|
||||||
note TEXT,
|
note TEXT,
|
||||||
estimated_cost REAL,
|
estimated_cost REAL,
|
||||||
|
|
@ -65,6 +66,14 @@ def init():
|
||||||
conn = connect()
|
conn = connect()
|
||||||
try:
|
try:
|
||||||
conn.executescript(SCHEMA)
|
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():
|
for key, value in DEFAULT_SETTINGS.items():
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)",
|
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)",
|
||||||
|
|
@ -127,8 +136,9 @@ def save_grade(grade, thumbnail=None, label=None):
|
||||||
" model, estimated_grade, "
|
" model, estimated_grade, "
|
||||||
" grade_low, grade_high, confidence, categories_json, "
|
" grade_low, grade_high, confidence, categories_json, "
|
||||||
" edge_measurements_json, centering_measurement_json, "
|
" edge_measurements_json, centering_measurement_json, "
|
||||||
|
" aspect_measurement_json, "
|
||||||
" limitations_json, note, estimated_cost, usage_json) "
|
" limitations_json, note, estimated_cost, usage_json) "
|
||||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
(
|
(
|
||||||
now(), label, grade.get("card_type"), grade.get("card_note"),
|
now(), label, grade.get("card_type"), grade.get("card_note"),
|
||||||
grade.get("image_count"), thumbnail,
|
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("categories")),
|
||||||
json.dumps(grade.get("edge_measurements")),
|
json.dumps(grade.get("edge_measurements")),
|
||||||
json.dumps(grade.get("centering_measurement")),
|
json.dumps(grade.get("centering_measurement")),
|
||||||
|
json.dumps(grade.get("aspect_measurement")),
|
||||||
json.dumps(grade.get("limitations")),
|
json.dumps(grade.get("limitations")),
|
||||||
grade.get("note"), grade.get("estimated_cost"),
|
grade.get("note"), grade.get("estimated_cost"),
|
||||||
json.dumps(grade.get("usage")),
|
json.dumps(grade.get("usage")),
|
||||||
|
|
@ -152,7 +163,8 @@ def save_grade(grade, thumbnail=None, label=None):
|
||||||
def _row_to_grade(row):
|
def _row_to_grade(row):
|
||||||
d = dict(row)
|
d = dict(row)
|
||||||
for key in ("categories_json", "edge_measurements_json",
|
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")]
|
out_key = key[:-len("_json")]
|
||||||
raw = d.pop(key, None)
|
raw = d.pop(key, None)
|
||||||
try:
|
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()
|
can_measure = zoom_details and images and cardimage.available()
|
||||||
measured = cardimage.edge_wear_profile(images[0][0]) if can_measure else None
|
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
|
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 = []
|
prompt_parts = []
|
||||||
if centering:
|
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 "
|
"strip images to describe what the wear looks like and to "
|
||||||
"catch what the measurement does not look for at all, such as "
|
"catch what the measurement does not look for at all, such as "
|
||||||
"a nick, a chip, or a crushed edge.")
|
"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:
|
if crops:
|
||||||
prompt_parts.append(
|
prompt_parts.append(
|
||||||
"The close-ups above are upscaled crops of the full card photo, each "
|
"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(),
|
"card_note": (parsed.get("card_note") or "").strip(),
|
||||||
"edge_measurements": measured,
|
"edge_measurements": measured,
|
||||||
"centering_measurement": centering,
|
"centering_measurement": centering,
|
||||||
|
"aspect_measurement": aspect,
|
||||||
"estimated_grade": _clean_grade(parsed.get("estimated_grade")),
|
"estimated_grade": _clean_grade(parsed.get("estimated_grade")),
|
||||||
"grade_low": low,
|
"grade_low": low,
|
||||||
"grade_high": high,
|
"grade_high": high,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue