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.
This commit is contained in:
tngo 2026-09-28 22:33:33 +09:00
parent aaed5d388f
commit ff1ed6ebc5
2 changed files with 92 additions and 16 deletions

View file

@ -26,8 +26,8 @@ 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 --replace
Views are named <sku>.jpg, <sku>_2.jpg, ... The CSV is carried over whole, so
text-only products stay in the catalog.
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
@ -286,11 +286,24 @@ def write_catalog(kept: Dict[str, List[str]], source: str, output: str, csv_path
for name in views:
shutil.copy2(os.path.join(source, name), os.path.join(staging, name))
if csv_path:
fieldnames, rows, _ = read_catalog(csv_path)
with open(os.path.join(staging, "stock.csv"), "w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
# 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)