diff --git a/app.py b/app.py index f6cea30..6680216 100644 --- a/app.py +++ b/app.py @@ -432,6 +432,12 @@ class Handler(BaseHTTPRequestHandler): # spending yours. Never stored — it lives in their browser and is # used for this one call. caller_key = (body.get("api_key") or "").strip() or None + # Cleared up-front, not just assigned on success. One handler + # instance serves every request on a keep-alive connection, so this + # attribute outlives the request that set it — without the reset, a + # future path that logs an event without reaching the assignment + # below would silently bill the previous request's payer. + self._last_used_own_key = False print("[grade] calling vision model={} on {} image(s){}…".format( model, len(images), " (caller key)" if caller_key else ""), flush=True) diff --git a/cardimage.py b/cardimage.py index 6583554..6b3fc36 100644 --- a/cardimage.py +++ b/cardimage.py @@ -939,6 +939,16 @@ def aspect_profile(image_bytes): bw, bh = box[2] - box[0], box[3] - box[1] if bw < 40 or bh < 40: return None + # This measurement IS the detected box, so a box that didn't find + # the card's real boundary doesn't produce an approximate ratio — + # it produces the ratio of something else entirely. On a cased card + # it reported 5.8% off standard, which is the CASE's proportions; + # the UI would then show a trimming hint about a card it never + # measured. Same guard as edge/centering, and more directly + # load-bearing here than for either of those. + margin_reason = _box_margin_reason(box, img.size) + if margin_reason: + return {"reliable": False, "reason": margin_reason} ratio = min(bw, bh) / float(max(bw, bh)) against_standards = { @@ -951,6 +961,7 @@ def aspect_profile(image_bytes): best_name = min(against_standards, key=lambda n: against_standards[n]["deviation_percent"]) return { + "reliable": True, "measured_ratio": round(ratio, 4), "width_px": bw, "height_px": bh, diff --git a/static/app.js b/static/app.js index 462947c..08360fb 100644 --- a/static/app.js +++ b/static/app.js @@ -157,7 +157,8 @@ function renderGradeBlock(g, opts = {}) { // 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 am = (g.aspect_measurement && g.aspect_measurement.reliable !== false) + ? 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) diff --git a/store.py b/store.py index 7e0643a..475326c 100644 --- a/store.py +++ b/store.py @@ -6,6 +6,7 @@ it told you, so you can look back at a card without re-running the estimate. """ import base64 +import binascii import json import os import sqlite3 @@ -202,8 +203,24 @@ def _decode_images(raw): items = json.loads(raw) except (ValueError, TypeError): return None - return [(base64.standard_b64decode(item["image_base64"]), item.get("filename") or "upload") - for item in items] + # Decoding is inside the guard too, not just the JSON parse: a row + # written by an older/newer shape, or truncated, would otherwise raise + # KeyError/binascii.Error out of a plain read and 500 the request. + # Falling back to "no stored photos" degrades to the re-pick path, + # which already exists and is the right outcome here. + try: + decoded = [(base64.standard_b64decode(item["image_base64"]), + item.get("filename") or "upload") + for item in items] + except (KeyError, TypeError, ValueError, binascii.Error): + return None + # b64decode doesn't raise on some malformed input, it just returns b'' + # — so a corrupt row would otherwise hand grading a zero-byte "photo" + # and fail confusingly deep in the pipeline. Treat any empty entry as + # the row being unusable and fall back to asking for the photo again. + if not decoded or any(not b for b, _ in decoded): + return None + return decoded def save_grade(grade, thumbnail=None, label=None, source_images=None): diff --git a/vision.py b/vision.py index ce8e8ac..3da35a8 100644 --- a/vision.py +++ b/vision.py @@ -951,7 +951,7 @@ 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: + if aspect and aspect.get("reliable") is not False: standard_rows = "\n".join( " {}: {:.4f} standard → {:.1f}% deviation".format( name, data["standard_ratio"], data["deviation_percent"])