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:
co-authored by
Claude Fable 5
parent
58956f626d
commit
4bf7481f9b
+4
-1
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user