product-recognition-demo/recognization/code/rebuild_catalog.py
tngo ff1ed6ebc5 feat(eval): score the shipped local lane, and harden catalog enrollment
The default backend could not be scored by the repo's own evaluator, so the
number that justifies the architecture lived only in an ad-hoc script.

- eval_identity.py: `--lanes local` wraps identity_lanes.LocalLane and
  TextLane behind the same four ceilings as every other lane. `Lane.decide`
  is now the seam: the default runs the shared gate over the lane's ranking,
  and the fused local+text lane overrides it, so the production decision is
  what gets scored rather than a re-implementation of it.
- rebuild_catalog.py: one-directional image/CSV match (an enrolled image with
  no row refuses; a row with no image is a text-only coverage gap), packshot
  OCR against the catalog name, capture-session refusal from an evaluation
  manifest, and the 160 px floor. The CSV is copied byte-for-byte so all 12
  rows survive a rebuild, and unmanaged files are carried over.

Reproduced from the repo, live resolution: 59% end-to-end at 95% precision,
2 false accepts of 48 unknowns, 20.6 ms per crop.
2026-09-28 22:33:33 +09:00

412 lines
19 KiB
Python

"""Rebuild a store catalog from packshots, refusing unsafe enrollment.
The LOPIA catalog shipped with views cut from the test shelf itself: 71-119 px
wide, one 18 px sliver, duplicate crops, and five SKUs with no view at all. That
is why every dark package collapsed onto one SKU and why the head was trained on
its own exam.
This rebuilds a catalog directory from named packshots and refuses, per view:
* a view shorter than MIN_HEIGHT or narrower than MIN_WIDTH,
* a view whose capture session appears in --eval-manifest,
* a packshot whose catalog name is not supported by its OCR text,
* a view that is a near-duplicate of another view of the same SKU,
* a SKU left with fewer than --min-views distinct views.
Every enrolled image must have a CSV row; a SKU with an image and no row refuses
the build. CSV rows with no image are kept and reported as text-only coverage
gaps, because the text lane names and vetoes those products from the CSV alone.
Capture session ids come from the source manifest (``manifest.json`` beside the
images by default), whose image entries use ``file`` plus
``capture_session_id`` or ``session_id``. Pixel equality is not a leak test:
re-cut crops pass it, so leakage is refused by capture session instead.
python code/rebuild_catalog.py --source /tmp/lopia-clean \
--output stock-lopia-clean --csv stock-lopia/stock.csv
python code/rebuild_catalog.py --source /tmp/lopia-clean \
--output stock-lopia-clean --csv stock-lopia/stock.csv --replace
Views are named <sku>.jpg, <sku>_2.jpg, ... The CSV is copied byte-for-byte, so
text-only products stay in the catalog and its content hash does not move.
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import shutil
import sys
import tempfile
import unicodedata
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
current_dir = os.path.dirname(os.path.abspath(__file__))
if current_dir not in sys.path:
sys.path.insert(0, current_dir)
import cv2 # noqa: E402
import numpy as np # noqa: E402
from ocr import OcrUnavailable, build_ocr_engine # noqa: E402
# A view narrower than this cannot carry the logo and text geometry that tells
# two fine-grained products apart. The shipped LOPIA views were 71-119 px.
MIN_WIDTH = 120
# The enrollment protocol's floor: a reference view under 160 px tall carries no
# text geometry (facings under 60 px wide failed every measured lane).
MIN_HEIGHT = 160
# Correct packshots measured 0.79-1.00 of the catalog name's bigrams in their
# OCR text; the wrong-size Kewpie 450g packshot tops out at 0.64 against every
# name in the catalog. 0.70 sits between them with room for OCR noise.
MIN_NAME_OCR = 0.70
# Vision drops small print at native scale: the l11 packshot reads ハーフ only
# when upscaled (0.57 -> 0.79). Reading at 2x is what makes the check about the
# package rather than about Vision's recall.
OCR_SCALE = 2.0
# Two views of one SKU this similar are the same photograph counted twice.
DUPLICATE_COSINE = 0.98
# One view cannot be checked against anything; two is the floor for a vote.
# Official pack-shot pages publish a single front view per JAN, so a catalog
# built from them runs at 1 and says so; the floor exists to catch accidental
# single-view enrollment from shelf crops, where several views are available.
MIN_VIEWS = 2
IMAGE_EXTENSIONS = (".jpg", ".jpeg", ".png")
SESSION_KEYS = ("capture_session_id", "session_id")
SESSION_LIST_KEYS = ("sessions", "session_ids", "evaluation_sessions")
class RebuildError(Exception):
"""Refused input. The destination, if it existed, is unchanged."""
def sku_of(filename: str) -> str:
"""`sku_l01_2.jpg` -> `sku_l01`; `sku_l01.jpg` -> `sku_l01`."""
stem = os.path.splitext(filename)[0]
parts = stem.split("_")
if len(parts) >= 2 and parts[0] == "sku":
return "_".join(parts[:2])
return stem
def view_rank(filename: str) -> int:
"""Order views so the primary view sorts first: `sku_l01.jpg` before `_2`."""
stem = os.path.splitext(filename)[0]
parts = stem.split("_")
return int(parts[2]) if len(parts) > 2 and parts[2].isdigit() else 1
def cosine(left: np.ndarray, right: np.ndarray) -> float:
a = left.astype(np.float32).ravel()
b = right.astype(np.float32).ravel()
denom = float(np.linalg.norm(a) * np.linalg.norm(b))
return float((a * b).sum() / denom) if denom else 0.0
def upscale(image: np.ndarray) -> np.ndarray:
"""Enlarge for OCR only; the stored view keeps its own pixels."""
return cv2.resize(image, None, fx=OCR_SCALE, fy=OCR_SCALE, interpolation=cv2.INTER_CUBIC)
def normalize_text(text: str) -> str:
"""NFKC, uppercase, small kana folded, alnum and `%` kept."""
value = unicodedata.normalize("NFKC", text).upper()
# Vision varies between small and full-size kana, and Kewpie prints
# キユーピー with a small ユ; folding both keeps the comparison about the
# product name rather than the typesetter.
value = value.translate(str.maketrans("ッャュョァィゥェォヮヵヶ", "ツヤユヨアイウエオワカケ"))
value = value.replace("キユーピー", "キユピ").replace("キューピー", "キユピ")
return "".join(char for char in value if char.isalnum() or char == "%")
def bigrams(text: str) -> Set[str]:
value = normalize_text(text)
return {value[index:index + 2] for index in range(len(value) - 1)}
def name_ocr_similarity(name: str, ocr_text: str) -> float:
"""Share of the catalog name's bigrams that the OCR text explains."""
name_bigrams = bigrams(name)
return len(name_bigrams & bigrams(ocr_text)) / max(1, len(name_bigrams))
def collect(source: str) -> Dict[str, List[str]]:
by_sku: Dict[str, List[str]] = {}
for name in sorted(os.listdir(source)):
if os.path.splitext(name)[1].lower() not in IMAGE_EXTENSIONS:
continue
by_sku.setdefault(sku_of(name), []).append(name)
for views in by_sku.values():
views.sort(key=view_rank)
return by_sku
def read_catalog(csv_path: str) -> Tuple[List[str], List[Dict[str, str]], Dict[str, str]]:
"""Columns, rows, and SKU -> product_name. Refuses a malformed catalog."""
with open(csv_path, newline="", encoding="utf-8-sig") as handle:
reader = csv.DictReader(handle)
fieldnames = list(reader.fieldnames or [])
rows = list(reader)
if "SKU_ID" not in fieldnames or "product_name" not in fieldnames:
raise RebuildError(f"{csv_path}: needs SKU_ID and product_name columns")
names: Dict[str, str] = {}
duplicates: Set[str] = set()
for row in rows:
sku = (row.get("SKU_ID") or "").strip()
if not sku:
raise RebuildError(f"{csv_path}: a row has an empty SKU_ID")
if sku in names:
duplicates.add(sku)
names[sku] = (row.get("product_name") or "").strip()
if duplicates:
raise RebuildError("duplicate CSV row(s): " + ", ".join(sorted(duplicates)))
return fieldnames, rows, names
def _session_values(payload: Any) -> Set[str]:
"""Every session id anywhere in a manifest, whatever its shape."""
found: Set[str] = set()
if isinstance(payload, dict):
for key, value in payload.items():
if key in SESSION_KEYS and isinstance(value, (str, int)):
found.add(str(value))
elif key in SESSION_LIST_KEYS and isinstance(value, list):
for item in value:
found.update([str(item)] if isinstance(item, (str, int)) else _session_values(item))
elif isinstance(value, (dict, list)):
found.update(_session_values(value))
elif isinstance(payload, list):
for item in payload:
found.update(_session_values(item))
return found
def load_eval_sessions(path: str) -> Set[str]:
"""Sessions an evaluation used. A manifest naming none is refused, not trusted."""
with open(path, encoding="utf-8") as handle:
sessions = _session_values(json.load(handle))
if not sessions:
raise RebuildError(f"{path}: evaluation manifest names no capture session")
return sessions
def load_capture_sessions(path: Optional[str]) -> Dict[str, str]:
"""Image basename -> capture session, from an enrollment manifest."""
if not path:
return {}
with open(path, encoding="utf-8") as handle:
payload = json.load(handle)
if isinstance(payload, dict):
entries = payload.get("images", [])
default = payload.get("capture_session_id") or payload.get("session_id")
else:
entries, default = payload, None
if not isinstance(entries, list):
raise RebuildError(f"{path}: 'images' must be a list")
sessions: Dict[str, str] = {}
for entry in entries:
if not isinstance(entry, dict):
continue
filename = entry.get("file") or entry.get("filename") or entry.get("image")
session = entry.get("capture_session_id") or entry.get("session_id") or default
if filename and session is not None:
sessions[os.path.basename(str(filename))] = str(session)
return sessions
def audit(
source: str,
min_views: int = MIN_VIEWS,
names: Optional[Dict[str, str]] = None,
ocr_engine: Any = None,
capture_sessions: Optional[Dict[str, str]] = None,
eval_sessions: Optional[Set[str]] = None,
) -> Tuple[Dict[str, List[str]], List[Dict[str, str]]]:
"""Keep the views that pass, and report every refusal with its reason."""
kept: Dict[str, List[str]] = {}
refused: List[Dict[str, str]] = []
capture_sessions = capture_sessions or {}
eval_sessions = eval_sessions or set()
for sku, views in sorted(collect(source).items()):
accepted: List[Tuple[str, np.ndarray]] = []
for name in views:
image = cv2.imread(os.path.join(source, name))
if image is None:
refused.append({"sku": sku, "view": name, "reason": "unreadable"})
continue
# Leakage first: a view from an evaluation session is refused even if
# it would fail another check, so the leak is never hidden by one.
session = capture_sessions.get(name)
if session and session in eval_sessions:
refused.append({"sku": sku, "view": name,
"reason": f"capture session {session} is in the evaluation manifest"})
continue
if image.shape[0] < MIN_HEIGHT:
refused.append({"sku": sku, "view": name,
"reason": f"{image.shape[0]}px tall, below the {MIN_HEIGHT}px floor"})
continue
if image.shape[1] < MIN_WIDTH:
refused.append({"sku": sku, "view": name,
"reason": f"{image.shape[1]}px wide, below the {MIN_WIDTH}px floor"})
continue
if ocr_engine is not None and names is not None:
try:
text = " ".join(line.text for line in ocr_engine.read(upscale(image)).lines)
except OcrUnavailable as error:
print(f"OCR skipped for {name}: {error}", file=sys.stderr)
else:
similarity = name_ocr_similarity(names.get(sku, ""), text)
if similarity < MIN_NAME_OCR:
refused.append({"sku": sku, "view": name,
"reason": f"name/OCR {similarity:.2f} below {MIN_NAME_OCR:.2f}: "
f"{text[:100] or '<no text read>'}"})
continue
duplicate = next((other for other, pixels in accepted
if image.shape == pixels.shape and cosine(image, pixels) >= DUPLICATE_COSINE), None)
if duplicate:
refused.append({"sku": sku, "view": name,
"reason": f"near-duplicate of {duplicate}"})
continue
accepted.append((name, image))
if len(accepted) < min_views:
for name, _ in accepted:
refused.append({"sku": sku, "view": name,
"reason": f"only {len(accepted)} usable view(s), {min_views} required"})
continue
kept[sku] = [name for name, _ in accepted]
return kept, refused
def write_catalog(kept: Dict[str, List[str]], source: str, output: str, csv_path: Optional[str]) -> None:
"""Copy the kept views and the whole CSV into a staging dir, then swap it in."""
staging = tempfile.mkdtemp(prefix=".catalog-", dir=os.path.dirname(os.path.abspath(output)) or ".")
try:
for views in kept.values():
for name in views:
shutil.copy2(os.path.join(source, name), os.path.join(staging, name))
if csv_path:
# Byte-for-byte, not re-serialized: gallery.catalog_digest hashes the
# raw file, and csv.DictWriter's default CRLF would move the digest
# and invalidate a trained identity head over a newline.
shutil.copy2(csv_path, os.path.join(staging, "stock.csv"))
if os.path.isdir(output):
# The documented rebuild runs with --source == --output, and the
# catalog directory also holds provenance this tool does not manage
# (README, references/rejected/, label files). Carry those over so a
# rebuild replaces the views and the CSV, not the record of where
# they came from.
for entry in os.listdir(output):
if entry == "stock.csv" or os.path.splitext(entry)[1].lower() in IMAGE_EXTENSIONS:
continue
origin, target = os.path.join(output, entry), os.path.join(staging, entry)
if os.path.isdir(origin):
shutil.copytree(origin, target)
else:
shutil.copy2(origin, target)
if os.path.exists(output):
shutil.rmtree(output)
os.replace(staging, output)
except BaseException:
shutil.rmtree(staging, ignore_errors=True)
raise
def main(argv: Optional[Sequence[str]] = None) -> int:
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--source", required=True, help="directory of pack shots")
parser.add_argument("--output", required=True, help="catalog directory to write")
parser.add_argument("--csv", help="stock.csv to validate and carry over whole")
parser.add_argument("--replace", action="store_true", help="overwrite an existing output")
parser.add_argument("--min-views", type=int, default=MIN_VIEWS,
help=f"views required per SKU (default {MIN_VIEWS}; "
"official pack-shot pages publish one front view per JAN)")
parser.add_argument("--ocr", choices=("auto", "vision", "none"), default=None,
help="packshot/name OCR backend (default: DORAYAKI_OCR, else vision "
"on macOS; an unavailable backend is skipped, not fatal)")
parser.add_argument("--source-manifest",
help="image capture metadata (default: SOURCE/manifest.json when present)")
parser.add_argument("--eval-manifest",
help="manifest of evaluation sessions; images captured in them are refused")
parser.add_argument("--json", help="write the audit here")
args = parser.parse_args(argv)
if os.path.exists(args.output) and not args.replace:
raise SystemExit(f"{args.output} exists; pass --replace to overwrite")
try:
if args.csv:
_, _, names = read_catalog(args.csv)
missing_rows = set(collect(args.source)) - set(names)
if missing_rows:
raise RebuildError("enrolled image SKU(s) with no CSV row: " + ", ".join(sorted(missing_rows)))
else:
names = {}
print("no --csv: image/CSV match and name/OCR checks skipped", file=sys.stderr)
source_manifest = args.source_manifest
default_manifest = os.path.join(args.source, "manifest.json")
if source_manifest is None and os.path.isfile(default_manifest):
source_manifest = default_manifest
capture_sessions = load_capture_sessions(source_manifest)
eval_sessions = load_eval_sessions(args.eval_manifest) if args.eval_manifest else set()
if eval_sessions:
unlabelled = sorted(name for views in collect(args.source).values() for name in views
if name not in capture_sessions)
if unlabelled:
print("eval-session check cannot cover image(s) with no capture_session_id: "
+ ", ".join(unlabelled), file=sys.stderr)
engine = build_ocr_engine(args.ocr)
ocr_engine = engine if engine.available() else None
if ocr_engine is None:
print("name/OCR check skipped: no OCR backend available", file=sys.stderr)
kept, refused = audit(args.source, min_views=args.min_views, names=names,
ocr_engine=ocr_engine, capture_sessions=capture_sessions,
eval_sessions=eval_sessions)
if refused:
print(f"refused {len(refused)} view(s):", file=sys.stderr)
for row in refused:
print(f" {row['sku']:<10} {row['view']:<20} {row['reason']}", file=sys.stderr)
if not kept:
raise RebuildError("no SKU has usable views; nothing written")
write_catalog(kept, args.source, args.output, args.csv)
except (OSError, ValueError, json.JSONDecodeError, RebuildError, OcrUnavailable) as error:
raise SystemExit(str(error)) from error
# A row with no kept image is a text-only entry: the text lane still names
# and vetoes it from the CSV alone, so it is a coverage gap, not a refusal.
text_only = sorted(set(names) - set(kept))
report = {
"source": args.source,
"output": args.output,
"min_width": MIN_WIDTH,
"min_height": MIN_HEIGHT,
"min_views": args.min_views,
"duplicate_cosine": DUPLICATE_COSINE,
"min_name_ocr": MIN_NAME_OCR,
"ocr": engine.name if ocr_engine is not None else "skipped",
"text_only_skus": text_only,
"skus": {sku: views for sku, views in sorted(kept.items())},
"refused": refused,
}
if args.json:
with open(args.json, "w", encoding="utf-8") as handle:
json.dump(report, handle, indent=1, ensure_ascii=False)
print(f"{len(kept)} SKU(s), {sum(len(v) for v in kept.values())} view(s) -> {args.output}")
for sku, views in sorted(kept.items()):
print(f" {sku:<10} {len(views)} view(s): {', '.join(views)}")
if text_only:
print(f"\ntext-only CSV row(s), no enrolled image ({len(text_only)}): {', '.join(text_only)}")
if refused:
print(f"\nrefused {len(refused)} view(s):")
for row in refused:
print(f" {row['sku']:<10} {row['view']:<20} {row['reason']}")
return 0
if __name__ == "__main__":
raise SystemExit(main())