Audit round 2: aspect measured the case too; defensive image decode; own-key state reset
4. aspect_profile had the same root cause as edges/centering but no guard. On the cased photo it reported 5.8% off standard -- the CASE's proportions -- and the UI would surface that as a trimming hint about a card it never measured. This one is more directly load-bearing than the other two, since the measurement IS the detected box. Now refuses, with both consumers (prompt block and UI hint) checking reliable. 5. _decode_images could KeyError/raise out of a plain read on a malformed row, and separately b64decode returns b'' rather than raising on some corrupt input -- which would have fed a zero-byte 'photo' into grading to fail confusingly deep in the pipeline. Both now degrade to the existing re-pick path. 6. _last_used_own_key is instance state on a handler that serves every request on a keep-alive connection, so it outlives the request that set it. Currently safe (every logging path assigns first), but a future path that logged without reaching the assignment would bill the previous request's payer. Cleared up-front now. Full regression suite re-run: die-cut exclusion, uniform cards, centering, aspect, cased-photo refusals, and the store layer all still behave.
This commit is contained in:
parent
6c612c1f4a
commit
0cbeb26665
5 changed files with 39 additions and 4 deletions
6
app.py
6
app.py
|
|
@ -432,6 +432,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
# spending yours. Never stored — it lives in their browser and is
|
# spending yours. Never stored — it lives in their browser and is
|
||||||
# used for this one call.
|
# used for this one call.
|
||||||
caller_key = (body.get("api_key") or "").strip() or None
|
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(
|
print("[grade] calling vision model={} on {} image(s){}…".format(
|
||||||
model, len(images), " (caller key)" if caller_key else ""), flush=True)
|
model, len(images), " (caller key)" if caller_key else ""), flush=True)
|
||||||
|
|
|
||||||
11
cardimage.py
11
cardimage.py
|
|
@ -939,6 +939,16 @@ def aspect_profile(image_bytes):
|
||||||
bw, bh = box[2] - box[0], box[3] - box[1]
|
bw, bh = box[2] - box[0], box[3] - box[1]
|
||||||
if bw < 40 or bh < 40:
|
if bw < 40 or bh < 40:
|
||||||
return None
|
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))
|
ratio = min(bw, bh) / float(max(bw, bh))
|
||||||
|
|
||||||
against_standards = {
|
against_standards = {
|
||||||
|
|
@ -951,6 +961,7 @@ def aspect_profile(image_bytes):
|
||||||
best_name = min(against_standards, key=lambda n: against_standards[n]["deviation_percent"])
|
best_name = min(against_standards, key=lambda n: against_standards[n]["deviation_percent"])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
"reliable": True,
|
||||||
"measured_ratio": round(ratio, 4),
|
"measured_ratio": round(ratio, 4),
|
||||||
"width_px": bw,
|
"width_px": bw,
|
||||||
"height_px": bh,
|
"height_px": bh,
|
||||||
|
|
|
||||||
|
|
@ -157,7 +157,8 @@ function renderGradeBlock(g, opts = {}) {
|
||||||
// would just be clutter, not information. Shown against its best-matching
|
// would just be clutter, not information. Shown against its best-matching
|
||||||
// standard; which standard is actually relevant is a judgment call the
|
// standard; which standard is actually relevant is a judgment call the
|
||||||
// model made with the photo in hand, not something this line re-derives.
|
// 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
|
const bestDev = am && am.against_standards && am.best_match
|
||||||
? am.against_standards[am.best_match].deviation_percent : null;
|
? am.against_standards[am.best_match].deviation_percent : null;
|
||||||
const aspectLine = (bestDev !== null && bestDev >= 3)
|
const aspectLine = (bestDev !== null && bestDev >= 3)
|
||||||
|
|
|
||||||
21
store.py
21
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 base64
|
||||||
|
import binascii
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
@ -202,8 +203,24 @@ def _decode_images(raw):
|
||||||
items = json.loads(raw)
|
items = json.loads(raw)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return None
|
return None
|
||||||
return [(base64.standard_b64decode(item["image_base64"]), item.get("filename") or "upload")
|
# Decoding is inside the guard too, not just the JSON parse: a row
|
||||||
for item in items]
|
# 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):
|
def save_grade(grade, thumbnail=None, label=None, source_images=None):
|
||||||
|
|
|
||||||
|
|
@ -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 "
|
"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:
|
if aspect and aspect.get("reliable") is not False:
|
||||||
standard_rows = "\n".join(
|
standard_rows = "\n".join(
|
||||||
" {}: {:.4f} standard → {:.1f}% deviation".format(
|
" {}: {:.4f} standard → {:.1f}% deviation".format(
|
||||||
name, data["standard_ratio"], data["deviation_percent"])
|
name, data["standard_ratio"], data["deviation_percent"])
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue