Extract reports unidentifiable boxes for human retakes

The vision prompt now returns {titles, unidentified}: boxes that look
like games but can't be confidently titled are reported (location
relative to identified neighbors, partial text, art notes) instead of
silently omitted. They land in data/unidentified.json keyed by photo,
and the end-of-run summary lists them — plus low-confidence reads —
with instructions to retake a closer photo and re-run. New --force flag
re-extracts everything; pre-feature raw caches (bare arrays) still
parse. Live run on IMG_4499 confirmed the flow and the low-confidence
list correctly flags the known "Hebarceos" misread.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-01 14:24:16 -04:00
co-authored by Claude Fable 5
parent 58956f626d
commit 4bf7481f9b
8 changed files with 284 additions and 54 deletions
+4 -1
View File
@@ -32,13 +32,16 @@ def extract(
only: Annotated[
str | None, typer.Option("--only", help="Re-run a single photo")
] = None,
force: Annotated[
bool, typer.Option("--force", help="Re-extract every photo (ignore cache)")
] = False,
config: ConfigOpt = None,
) -> None:
"""Stage 1: extract game titles + edition cues from shelf photos."""
from bggpipe.extract import run_extract
cfg = load_config(config)
run_extract(cfg, only=only)
run_extract(cfg, only=only, force=force)
@app.command()
+4
View File
@@ -31,6 +31,10 @@ class Config:
def titles_path(self) -> Path:
return self.data_dir / "titles.json"
@property
def unidentified_path(self) -> Path:
return self.data_dir / "unidentified.json"
@property
def matches_path(self) -> Path:
return self.data_dir / "matches.csv"
+144 -36
View File
@@ -40,22 +40,45 @@ and anything that is not a board/card game.
If the SAME title appears more than once with visibly different boxes, report
each as a separate entry — the owner has multiple editions of some games.
Respond with ONLY a JSON array, no prose, where each element is:
Respond with ONLY a JSON object, no prose:
{
"title_raw": "title as printed on the box",
"confidence": "high" | "medium" | "low",
"publisher_hint": "publisher name or logo if legible, else null",
"edition_hint": "edition wording if visible ('2nd Edition', 'Deluxe',
'Big Box', anniversary marks), else null",
"year_hint": copyright or publication year as an integer, ONLY if printed
as publishing info (copyright line, edition year). NEVER use
a year that is part of the game's title, theme, or subject
matter — a wargame about 1942 is not published in 1942.
When unsure whether a year is thematic, use null,
"language_hint": "language of the box text if determinable, else null",
"art_notes": "distinctive box-art notes (colorway, artwork style) that
could identify the edition, else null"
"titles": [
{
"title_raw": "title as printed on the box",
"confidence": "high" | "medium" | "low",
"publisher_hint": "publisher name or logo if legible, else null",
"edition_hint": "edition wording if visible ('2nd Edition', 'Deluxe',
'Big Box', anniversary marks), else null",
"year_hint": copyright or publication year as an integer, ONLY if
printed as publishing info (copyright line, edition
year). NEVER use a year that is part of the game's
title, theme, or subject matter — a wargame about 1942
is not published in 1942. When unsure whether a year is
thematic, use null,
"language_hint": "language of the box text if determinable, else null",
"art_notes": "distinctive box-art notes (colorway, artwork style)
that could identify the edition, else null"
}
],
"unidentified": [
{
"location": "where a person standing at this shelf would find the
box: shelf row, position, and the identified games on
either side of it",
"partial_text": "any letters or word fragments you can make out,
else null",
"art_notes": "color, artwork, box size/shape — anything that would
help identify it"
}
]
}
"unidentified" is for boxes that appear to be games but whose title you
CANNOT confidently transcribe — too blurry, obscured, at too sharp an
angle, or cut off at the frame edge. It is ALWAYS better to report an
unidentifiable box here than to silently omit it, and better here than
guessing a title into "titles". Never list the same box in both arrays.
Use an empty array when everything is identified.
"""
@@ -80,18 +103,37 @@ def prepare_image(path: Path) -> tuple[str, str]:
return base64.standard_b64encode(buffer.getvalue()).decode(), "image/jpeg"
def parse_vision_json(text: str) -> list[dict]:
"""Parse the model's JSON defensively: strip code fences, find the array."""
def parse_vision_response(text: str) -> tuple[list[dict], list[dict]]:
"""Parse the model's JSON defensively: strip code fences, locate the
payload amid any prose. Returns (title entries, unidentified sightings).
A bare JSON array (the pre-unidentified response shape) still parses —
it's all titles."""
cleaned = _CODE_FENCE.sub("", text).strip()
if not cleaned.startswith("["):
start, end = cleaned.find("["), cleaned.rfind("]")
if start == -1 or end == -1:
raise ValueError(f"no JSON array in vision response: {text[:200]!r}")
if cleaned[:1] not in ("[", "{"):
starts = [i for i in (cleaned.find("["), cleaned.find("{")) if i != -1]
if not starts:
raise ValueError(f"no JSON in vision response: {text[:200]!r}")
start = min(starts)
end = cleaned.rfind("]" if cleaned[start] == "[" else "}")
if end == -1:
raise ValueError(f"unterminated JSON in vision response: {text[:200]!r}")
cleaned = cleaned[start : end + 1]
entries = json.loads(cleaned)
if not isinstance(entries, list):
raise ValueError("vision response is not a JSON array")
return [e for e in entries if isinstance(e, dict) and e.get("title_raw")]
data = json.loads(cleaned)
if isinstance(data, list):
titles_raw, unidentified_raw = data, []
elif isinstance(data, dict):
titles_raw = data.get("titles") or []
unidentified_raw = data.get("unidentified") or []
else:
raise ValueError("vision response is neither a JSON object nor array")
titles = [e for e in titles_raw if isinstance(e, dict) and e.get("title_raw")]
unidentified = [
u
for u in unidentified_raw
if isinstance(u, dict)
and (u.get("location") or u.get("partial_text") or u.get("art_notes"))
]
return titles, unidentified
def default_vision(model: str) -> VisionFn:
@@ -126,9 +168,19 @@ def default_vision(model: str) -> VisionFn:
return vision
def extract_photo(photo: Path, vision: VisionFn) -> list[dict]:
def extract_photo(photo: Path, vision: VisionFn) -> dict:
"""One photo -> {"titles": [...], "unidentified": [...]} (the raw-cache
file format)."""
image_b64, media_type = prepare_image(photo)
raw_entries = parse_vision_json(vision(image_b64, media_type))
raw_entries, raw_unidentified = parse_vision_response(vision(image_b64, media_type))
unidentified = [
{
"location": str(u.get("location") or "").strip(),
"partial_text": str(u.get("partial_text") or "").strip(),
"art_notes": str(u.get("art_notes") or "").strip(),
}
for u in raw_unidentified
]
entries = []
for raw in raw_entries:
year = raw.get("year_hint")
@@ -146,7 +198,7 @@ def extract_photo(photo: Path, vision: VisionFn) -> list[dict]:
"source_photos": [photo.name],
}
)
return entries
return {"titles": entries, "unidentified": unidentified}
def _cues_conflict(a: dict, b: dict) -> bool:
@@ -194,18 +246,38 @@ def dedupe_entries(entries: list[dict]) -> list[dict]:
return result
def rebuild_titles(raw_dir: Path, titles_path: Path) -> list[dict]:
def rebuild_artifacts(
raw_dir: Path, titles_path: Path, unidentified_path: Path
) -> tuple[list[dict], dict[str, list[dict]]]:
"""Regenerate titles.json and unidentified.json from the per-photo raw
cache. Raw files written before the unidentified feature are bare
arrays — still readable."""
entries: list[dict] = []
unidentified: dict[str, list[dict]] = {}
for raw_file in sorted(raw_dir.glob("*.json")):
entries.extend(json.loads(raw_file.read_text()))
data = json.loads(raw_file.read_text())
if isinstance(data, list): # legacy format
entries.extend(data)
continue
entries.extend(data.get("titles") or [])
photo = raw_file.name.removesuffix(".json")
if data.get("unidentified"):
unidentified[photo] = data["unidentified"]
deduped = dedupe_entries(entries)
titles_path.parent.mkdir(parents=True, exist_ok=True)
titles_path.write_text(json.dumps(deduped, indent=2, ensure_ascii=False) + "\n")
return deduped
unidentified_path.write_text(
json.dumps(unidentified, indent=2, ensure_ascii=False) + "\n"
)
return deduped, unidentified
def run_extract(
cfg: Config, *, only: str | None = None, vision: VisionFn | None = None
cfg: Config,
*,
only: str | None = None,
force: bool = False,
vision: VisionFn | None = None,
) -> list[dict]:
photos = (
sorted(
@@ -232,13 +304,49 @@ def run_extract(
for photo in photos:
raw_path = raw_dir / f"{photo.name}.json"
if raw_path.exists() and not only:
if raw_path.exists() and not only and not force:
typer.echo(f" {photo.name}: already extracted, skipping")
continue
entries = extract_photo(photo, vision)
raw_path.write_text(json.dumps(entries, indent=2, ensure_ascii=False) + "\n")
typer.echo(f" {photo.name}: {len(entries)} title(s)")
result = extract_photo(photo, vision)
raw_path.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n")
note = (
f" ({len(result['unidentified'])} unidentified)"
if result["unidentified"]
else ""
)
typer.echo(f" {photo.name}: {len(result['titles'])} title(s){note}")
deduped = rebuild_titles(raw_dir, cfg.titles_path)
deduped, unidentified = rebuild_artifacts(
raw_dir, cfg.titles_path, cfg.unidentified_path
)
typer.echo(f"Wrote {len(deduped)} unique title(s) to {cfg.titles_path}.")
if unidentified:
typer.echo(
"\nSaw but couldn't identify — take a closer photo of each, drop "
"it in photos/, and run extract again:"
)
for photo_name, sightings in unidentified.items():
for s in sightings:
detail = "; ".join(
part
for part in (
s["location"],
f"text visible: {s['partial_text']!r}"
if s["partial_text"]
else "",
s["art_notes"],
)
if part
)
typer.echo(f" [{photo_name}] {detail}")
shaky = [e for e in deduped if e["confidence"] != "high"]
if shaky:
typer.echo("\nLow-confidence reads worth double-checking:")
for e in shaky:
typer.echo(
f" {e['title_raw']!r} ({e['confidence']}) in "
f"{', '.join(e['source_photos'])}"
)
return deduped