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:
parent
deabe74593
commit
6c612c1f4a
3 changed files with 79 additions and 12 deletions
25
cardimage.py
25
cardimage.py
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
36
vision.py
36
vision.py
|
|
@ -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"],
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue