From 6c612c1f4a5228a45cd1d9f0da47c8e38fe67858 Mon Sep 17 00:00:00 2001 From: Barely Removable Date: Tue, 25 Aug 2026 08:00:48 -0700 Subject: [PATCH] Audit fixes: mis-framed crops, GPT cost 2.2x low, camera button losing photos 1. detail_crops had no equivalent of the margin guard the measurements got. When box detection fails (card still in a case), every crop is cut relative to the wrong rectangle -- verified the 'TOP-LEFT CORNER' close-up of the cased Ohtani is actually the CASE's corner bracket. The measurements refuse and explain; the crops kept being produced and captioned authoritatively, and the prompt tells the model to judge corners/edges/surface *from* them. Now surfaces a framing caveat telling the model to locate the real card edge inside each crop and say cannot_assess rather than grade the holder. 2. gpt-5.6-sol's approx_image_tokens was a pre-launch guess (1500) that advertised ~2.2x under true cost across all 9 real calls. Recalibrated to 3560 against median real usage, and added a per-model output estimate since GPT writes ~1.2k tokens of verdict vs Sonnet's ~0.9k. Both models now advertise within ~3% of observed cost. 3. 'Take photo' didn't reset after a completed grade, unlike 'Choose from library'. The result view has no photo strip, so new photos piled up invisibly behind the old verdict, silently, to the 6-photo cap. Also fixed the cap itself being a silent no-op with no explanation. --- cardimage.py | 25 +++++++++++++++++++++++++ static/app.js | 30 +++++++++++++++++++++++++----- vision.py | 36 +++++++++++++++++++++++++++++------- 3 files changed, 79 insertions(+), 12 deletions(-) diff --git a/cardimage.py b/cardimage.py index 397c002..6583554 100644 --- a/cardimage.py +++ b/cardimage.py @@ -287,6 +287,31 @@ def _box_margin_reason(box, img_size): return None +def framing_warning(image_bytes): + """The margin problem described in _box_margin_reason, or None. + + Public because the CROPS have the same exposure to it as the pixel + measurements do, and worse consequences. When box detection can't find + the card's real boundary, every crop is cut relative to the wrong + rectangle — a "TOP-LEFT CORNER" close-up of a cased card shows the + CASE's corner bracket, with the card's actual corner off to one side. + The measurements at least refuse and say why; the crops keep being + produced and keep being captioned authoritatively, and the grading + prompt tells the model to judge corners/edges/surface *from* them. So + the caller needs to be able to warn about the framing rather than + silently pass off plastic as cardstock. + """ + if Image is None: + return None + try: + img = Image.open(io.BytesIO(image_bytes)) + img.load() + box = _detect_card_box(img) or (0, 0, img.width, img.height) + return _box_margin_reason(box, img.size) + except Exception: + return None + + def _surface_map(piece): """A band-pass view that isolates surface texture from the artwork. diff --git a/static/app.js b/static/app.js index 0d8c7e1..462947c 100644 --- a/static/app.js +++ b/static/app.js @@ -330,9 +330,22 @@ function resetGradeState() { renderGradeReview(); } +// Matches MAX_GRADE_IMAGES in app.py — the server rejects more than this, +// so the UI has to stop at the same number rather than let someone pick a +// seventh photo and only find out when grading fails. +const MAX_PHOTOS = 6; + async function addPickedFrom(input) { + const room = MAX_PHOTOS - gradeState.files.length; + if (room <= 0) { + // Was a silent no-op: readPickedImages would slice to nothing and + // return an empty array, so the tap did nothing with no explanation. + banner(`That's the ${MAX_PHOTOS}-photo limit for one card — remove one, or grade these.`, true); + input.value = ''; + return; + } try { - const picked = await readPickedImages(input, 6 - gradeState.files.length); + const picked = await readPickedImages(input, room); if (!picked.length) return; gradeState.files.push(...picked); renderGradeReview(); @@ -348,10 +361,17 @@ $('#btn-grade').addEventListener('click', () => { }); $('#grade-file').addEventListener('change', (e) => addPickedFrom(e.target)); -// Camera shots add onto whatever's already picked (front, then flip to the -// back for a second shot) rather than resetting — resetGradeState() is only -// for starting a fresh card, which the library button already does. -$('#btn-camera').addEventListener('click', () => $('#grade-camera').click()); +// Camera shots add onto whatever's already PICKED (front, then flip to the +// back for a second shot) rather than resetting. But once a result is on +// screen that card is finished, and adding to it is meaningless: the result +// view has no photo strip, so renderGradeReview would keep showing the old +// verdict while the new photos piled up invisibly behind it — silently, and +// all the way to the 6-photo cap. Starting a new card is the only sensible +// reading of "take a photo" at that point. +$('#btn-camera').addEventListener('click', () => { + if (gradeState.result) resetGradeState(); + $('#grade-camera').click(); +}); $('#grade-camera').addEventListener('change', (e) => addPickedFrom(e.target)); $('#grade-review').addEventListener('click', (e) => { if (e.target.closest('#grade-add-more')) { $('#grade-file').click(); return; } diff --git a/vision.py b/vision.py index 0481e44..ce8e8ac 100644 --- a/vision.py +++ b/vision.py @@ -85,12 +85,14 @@ MODELS = { "adaptive_thinking": False, "fallbacks": False, "in_per_mtok": 2.00, "out_per_mtok": 10.00, - # A rough estimate, unlike the Anthropic figures (which were true'd - # up against real usage — see the README's grading-cost note). Only - # affects the ADVERTISED per-grade estimate in Settings; the actual - # billed cost always comes from the real usage this API call - # reports, never from this number. - "approx_image_tokens": 1500, + # Calibrated against 8 real single/two-photo grades once this model + # had actually been used (median 18.4k in / 1.2k out). The initial + # 1500 was a guess made before any real call existed and advertised + # roughly 2.2x under the true cost — a guess is fine to start from, + # but it has to be trued up once real usage exists, exactly as the + # Anthropic figures were. + "approx_image_tokens": 3560, + "approx_output_tokens": 1180, }, } @@ -853,6 +855,9 @@ def grade_card(images, api_key=None, model=None, effort=None, zoom_details=True) 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 + # Same detection failure that makes the measurements refuse also + # mis-frames every crop — see cardimage.framing_warning. + framing = cardimage.framing_warning(images[0][0]) if (crops or can_measure) else None prompt_parts = [] if centering and centering.get("reliable") is False: @@ -986,6 +991,20 @@ def grade_card(images, api_key=None, model=None, effort=None, zoom_details=True) "when you say which corner or edge a problem is on. They add no " "information the full photo lacked, only easier viewing, so do not " "read resampling softness as card wear.") + if framing: + prompt_parts.append( + "IMPORTANT CAVEAT ON THOSE CROPS: {} Because the crop " + "rectangle is derived from that same detection, each close-up " + "is cut relative to the wrong boundary — a corner close-up may " + "well be showing the CORNER OF A CASE, SLEEVE OR HOLDER with " + "the card's own corner sitting somewhere inside the frame, or " + "out of it. Do not assume the crop's own edge is the card's " + "edge. Find the actual card edge inside each close-up first " + "and judge only that; if a given close-up doesn't clearly " + "contain the card's real corner or edge, say cannot_assess " + "for that category rather than grading the holder. Damage, " + "scuffing or whitening on a case is not damage to the " + "card.".format(framing)) prompt_parts.append("Estimate the PSA grade this trading card would likely receive.") parsed, usage = _call_vision( @@ -1084,8 +1103,11 @@ def price_guide(): guide = {} for model_id, caps in MODELS.items(): # 1 supplied photo + ~12 generated close-ups, JSON verdict out. + # Per-model output estimate where one is known: GPT-5.6 Sol + # reliably writes ~1.2k tokens of verdict against Sonnet's ~0.9k, + # enough to matter at these prices. est_in = caps["approx_image_tokens"] * 5 + 600 - est_out = 700 + est_out = caps.get("approx_output_tokens", 700) rate_in, rate_out = current_rates(caps) guide[model_id] = { "label": caps["label"],