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.
This commit is contained in:
Barely Removable 2026-08-25 08:00:48 -07:00
parent deabe74593
commit 6c612c1f4a
3 changed files with 79 additions and 12 deletions

View file

@ -287,6 +287,31 @@ def _box_margin_reason(box, img_size):
return None 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): def _surface_map(piece):
"""A band-pass view that isolates surface texture from the artwork. """A band-pass view that isolates surface texture from the artwork.

View file

@ -330,9 +330,22 @@ function resetGradeState() {
renderGradeReview(); 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) { 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 { try {
const picked = await readPickedImages(input, 6 - gradeState.files.length); const picked = await readPickedImages(input, room);
if (!picked.length) return; if (!picked.length) return;
gradeState.files.push(...picked); gradeState.files.push(...picked);
renderGradeReview(); renderGradeReview();
@ -348,10 +361,17 @@ $('#btn-grade').addEventListener('click', () => {
}); });
$('#grade-file').addEventListener('change', (e) => addPickedFrom(e.target)); $('#grade-file').addEventListener('change', (e) => addPickedFrom(e.target));
// Camera shots add onto whatever's already picked (front, then flip to the // Camera shots add onto whatever's already PICKED (front, then flip to the
// back for a second shot) rather than resetting — resetGradeState() is only // back for a second shot) rather than resetting. But once a result is on
// for starting a fresh card, which the library button already does. // screen that card is finished, and adding to it is meaningless: the result
$('#btn-camera').addEventListener('click', () => $('#grade-camera').click()); // 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-camera').addEventListener('change', (e) => addPickedFrom(e.target));
$('#grade-review').addEventListener('click', (e) => { $('#grade-review').addEventListener('click', (e) => {
if (e.target.closest('#grade-add-more')) { $('#grade-file').click(); return; } if (e.target.closest('#grade-add-more')) { $('#grade-file').click(); return; }

View file

@ -85,12 +85,14 @@ MODELS = {
"adaptive_thinking": False, "adaptive_thinking": False,
"fallbacks": False, "fallbacks": False,
"in_per_mtok": 2.00, "out_per_mtok": 10.00, "in_per_mtok": 2.00, "out_per_mtok": 10.00,
# A rough estimate, unlike the Anthropic figures (which were true'd # Calibrated against 8 real single/two-photo grades once this model
# up against real usage — see the README's grading-cost note). Only # had actually been used (median 18.4k in / 1.2k out). The initial
# affects the ADVERTISED per-grade estimate in Settings; the actual # 1500 was a guess made before any real call existed and advertised
# billed cost always comes from the real usage this API call # roughly 2.2x under the true cost — a guess is fine to start from,
# reports, never from this number. # but it has to be trued up once real usage exists, exactly as the
"approx_image_tokens": 1500, # 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 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 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 = [] prompt_parts = []
if centering and centering.get("reliable") is False: 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 " "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 " "information the full photo lacked, only easier viewing, so do not "
"read resampling softness as card wear.") "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.") prompt_parts.append("Estimate the PSA grade this trading card would likely receive.")
parsed, usage = _call_vision( parsed, usage = _call_vision(
@ -1084,8 +1103,11 @@ def price_guide():
guide = {} guide = {}
for model_id, caps in MODELS.items(): for model_id, caps in MODELS.items():
# 1 supplied photo + ~12 generated close-ups, JSON verdict out. # 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_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) rate_in, rate_out = current_rates(caps)
guide[model_id] = { guide[model_id] = {
"label": caps["label"], "label": caps["label"],