Add Regrade: instant re-run when photos are stored, fallback re-pick otherwise
Stores the original photo(s) for every card graded from now on, so an existing history row can be re-graded in place without re-uploading -- most useful right after a grading-logic fix like the last few commits. Cards graded before this feature (or saved without images) fall back to the same photo picker as a fresh grade, then start getting instant regrades from then on. list_grades/get_grade now select an explicit column list rather than SELECT * so the stored images are never pulled into memory just to be discarded for the browser-facing response.
This commit is contained in:
parent
552f862f4c
commit
e586bc92e7
4 changed files with 302 additions and 39 deletions
145
store.py
145
store.py
|
|
@ -5,6 +5,7 @@ does exactly one thing (estimate a PSA grade from photos) and remembers what
|
|||
it told you, so you can look back at a card without re-running the estimate.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
|
|
@ -42,7 +43,10 @@ CREATE TABLE IF NOT EXISTS grades (
|
|||
limitations_json TEXT,
|
||||
note TEXT,
|
||||
estimated_cost REAL,
|
||||
usage_json TEXT
|
||||
usage_json TEXT,
|
||||
source_images_json TEXT -- the original photo(s), for Regrade; NOT sent
|
||||
-- to the browser in list/detail responses —
|
||||
-- see get_grade_images vs get_grade/list_grades
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_grades_created ON grades(created_at DESC);
|
||||
|
|
@ -70,10 +74,14 @@ def init():
|
|||
# so a column added after cards were already graded needs its own
|
||||
# migration — guarded because re-running this against a database
|
||||
# that already has the column would otherwise error every startup.
|
||||
try:
|
||||
conn.execute("ALTER TABLE grades ADD COLUMN aspect_measurement_json TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
for statement in (
|
||||
"ALTER TABLE grades ADD COLUMN aspect_measurement_json TEXT",
|
||||
"ALTER TABLE grades ADD COLUMN source_images_json TEXT",
|
||||
):
|
||||
try:
|
||||
conn.execute(statement)
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
for key, value in DEFAULT_SETTINGS.items():
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)",
|
||||
|
|
@ -126,8 +134,35 @@ def save_settings(updates):
|
|||
# ------------------------------------------------------------------ grades
|
||||
|
||||
|
||||
def save_grade(grade, thumbnail=None, label=None):
|
||||
"""Persist one grading result. Returns the new row's id."""
|
||||
def _encode_images(images):
|
||||
"""(bytes, filename) pairs -> the JSON text stored in source_images_json."""
|
||||
if not images:
|
||||
return None
|
||||
return json.dumps([
|
||||
{"filename": name, "image_base64": base64.standard_b64encode(b).decode("ascii")}
|
||||
for b, name in images
|
||||
])
|
||||
|
||||
|
||||
def _decode_images(raw):
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
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]
|
||||
|
||||
|
||||
def save_grade(grade, thumbnail=None, label=None, source_images=None):
|
||||
"""Persist one grading result. Returns the new row's id.
|
||||
|
||||
`source_images` are the original (bytes, filename) pairs that produced
|
||||
this grade, kept so Regrade can re-run without asking for the photo(s)
|
||||
again. Optional — a caller that skips this still gets everything else;
|
||||
Regrade just falls back to prompting for photos on that row.
|
||||
"""
|
||||
conn = connect()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
|
|
@ -137,8 +172,9 @@ def save_grade(grade, thumbnail=None, label=None):
|
|||
" grade_low, grade_high, confidence, categories_json, "
|
||||
" edge_measurements_json, centering_measurement_json, "
|
||||
" aspect_measurement_json, "
|
||||
" limitations_json, note, estimated_cost, usage_json) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
" limitations_json, note, estimated_cost, usage_json, "
|
||||
" source_images_json) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
now(), label, grade.get("card_type"), grade.get("card_note"),
|
||||
grade.get("image_count"), thumbnail,
|
||||
|
|
@ -152,6 +188,7 @@ def save_grade(grade, thumbnail=None, label=None):
|
|||
json.dumps(grade.get("limitations")),
|
||||
grade.get("note"), grade.get("estimated_cost"),
|
||||
json.dumps(grade.get("usage")),
|
||||
_encode_images(source_images),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
|
@ -160,6 +197,69 @@ def save_grade(grade, thumbnail=None, label=None):
|
|||
conn.close()
|
||||
|
||||
|
||||
def update_grade_result(grade_id, grade, thumbnail=None, source_images=None):
|
||||
"""Overwrite a grade's result in place (Regrade) — label is untouched.
|
||||
|
||||
created_at is bumped to now, so the card floats back to the top of
|
||||
History as the most recently-active one, same as if it were freshly
|
||||
graded. `source_images` only overwrites the stored photo(s) when the
|
||||
caller actually supplies new ones (the re-pick fallback path); passing
|
||||
None leaves whatever was already stored for this row alone.
|
||||
"""
|
||||
conn = connect()
|
||||
try:
|
||||
params = [
|
||||
now(), grade.get("card_type"), grade.get("card_note"),
|
||||
grade.get("image_count"),
|
||||
(grade.get("usage") or {}).get("model"),
|
||||
grade.get("estimated_grade"), grade.get("grade_low"),
|
||||
grade.get("grade_high"), grade.get("confidence"),
|
||||
json.dumps(grade.get("categories")),
|
||||
json.dumps(grade.get("edge_measurements")),
|
||||
json.dumps(grade.get("centering_measurement")),
|
||||
json.dumps(grade.get("aspect_measurement")),
|
||||
json.dumps(grade.get("limitations")),
|
||||
grade.get("note"), grade.get("estimated_cost"),
|
||||
json.dumps(grade.get("usage")),
|
||||
]
|
||||
sql = (
|
||||
"UPDATE grades SET created_at=?, card_type=?, card_note=?, "
|
||||
"image_count=?, model=?, estimated_grade=?, grade_low=?, "
|
||||
"grade_high=?, confidence=?, categories_json=?, "
|
||||
"edge_measurements_json=?, centering_measurement_json=?, "
|
||||
"aspect_measurement_json=?, limitations_json=?, note=?, "
|
||||
"estimated_cost=?, usage_json=?"
|
||||
)
|
||||
if thumbnail is not None:
|
||||
sql += ", thumbnail=?"
|
||||
params.append(thumbnail)
|
||||
if source_images is not None:
|
||||
sql += ", source_images_json=?"
|
||||
params.append(_encode_images(source_images))
|
||||
sql += " WHERE id=?"
|
||||
params.append(grade_id)
|
||||
conn.execute(sql, params)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
return get_grade(grade_id)
|
||||
|
||||
|
||||
def get_grade_images(grade_id):
|
||||
"""The original (bytes, filename) pairs for Regrade, or None if this
|
||||
grade never had them stored (a card graded before this feature existed,
|
||||
or one saved without the images path). Server-side use only — never
|
||||
sent to the browser, unlike everything get_grade/list_grades return."""
|
||||
conn = connect()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT source_images_json FROM grades WHERE id = ?", (grade_id,)
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
return _decode_images(row["source_images_json"]) if row else None
|
||||
|
||||
|
||||
def _row_to_grade(row):
|
||||
d = dict(row)
|
||||
for key in ("categories_json", "edge_measurements_json",
|
||||
|
|
@ -171,14 +271,34 @@ def _row_to_grade(row):
|
|||
d[out_key] = json.loads(raw) if raw else None
|
||||
except (ValueError, TypeError):
|
||||
d[out_key] = None
|
||||
# The raw photo(s) are only ever for the regrade endpoint to read
|
||||
# server-side (see get_grade_images) — swapping this for a boolean here
|
||||
# keeps every browser-facing response light, the same reason thumbnails
|
||||
# are pre-shrunk rather than sending the original photo for display.
|
||||
d["has_source_images"] = bool(d.pop("source_images_json", None))
|
||||
return d
|
||||
|
||||
|
||||
# Every column except source_images_json — that one is only ever read
|
||||
# through get_grade_images, so a plain SELECT * here would pull the full
|
||||
# original photo(s) off disk for every row just to discard them a moment
|
||||
# later in _row_to_grade, silently defeating the whole point of keeping
|
||||
# list/detail responses light.
|
||||
_LIST_COLUMNS = (
|
||||
"id, created_at, label, card_type, card_note, image_count, thumbnail, "
|
||||
"model, estimated_grade, grade_low, grade_high, confidence, "
|
||||
"categories_json, edge_measurements_json, centering_measurement_json, "
|
||||
"aspect_measurement_json, limitations_json, note, estimated_cost, "
|
||||
"usage_json, (source_images_json IS NOT NULL) AS source_images_json"
|
||||
)
|
||||
|
||||
|
||||
def list_grades(limit=200):
|
||||
conn = connect()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM grades ORDER BY created_at DESC, id DESC LIMIT ?",
|
||||
"SELECT {} FROM grades ORDER BY created_at DESC, id DESC LIMIT ?"
|
||||
.format(_LIST_COLUMNS),
|
||||
(limit,),
|
||||
).fetchall()
|
||||
finally:
|
||||
|
|
@ -189,7 +309,10 @@ def list_grades(limit=200):
|
|||
def get_grade(grade_id):
|
||||
conn = connect()
|
||||
try:
|
||||
row = conn.execute("SELECT * FROM grades WHERE id = ?", (grade_id,)).fetchone()
|
||||
row = conn.execute(
|
||||
"SELECT {} FROM grades WHERE id = ?".format(_LIST_COLUMNS),
|
||||
(grade_id,),
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
return _row_to_grade(row) if row else None
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue