diff --git a/recognization/code/eval_identity.py b/recognization/code/eval_identity.py index 5cc5c5b..01e3dfe 100644 --- a/recognization/code/eval_identity.py +++ b/recognization/code/eval_identity.py @@ -50,6 +50,8 @@ import numpy as np # noqa: E402 from rebuild_catalog import sku_of # noqa: E402 +from identity import LOCAL_ACCEPT # noqa: E402 + UNKNOWN = "unknown" # Two independent label passes disagreed here, so the row is not ground truth. # Scoring it would be scoring a coin flip. @@ -223,6 +225,19 @@ class Lane: """Candidate SKUs the verifier is allowed to consider.""" return [sku for sku, _ in self.rank(crop)[:size]] + def decide(self, crop: Any, ranked: List[Tuple[str, float]], *, accept: float, + margin: float, scale: str) -> Dict[str, Any]: + """The lane's own decision. Default: the shared gate over its ranking. + + A lane whose decision is not `decide_identity` over one ranking - the + fused local+text lane is one - overrides this, so every lane is still + scored through the same four ceilings. + """ + from identity import decide_identity + + return decide_identity(dict(ranked), method=self.name, scale=scale, + min_score=accept, min_margin=margin) + class MatcherLane(Lane): """The original demo: local features and RANSAC over the whole catalog.""" @@ -240,6 +255,52 @@ class MatcherLane(Lane): return sorted(group_by_sku(scores).items(), key=lambda kv: kv[1], reverse=True) +class LocalLaneAdapter(Lane): + """The shipped default: RootSIFT pyramid plus the OCR text lane. + + Wraps `identity_lanes` so the production decision is what gets scored, + rather than a re-implementation of it. `rank` returns the lane's own order + (its rank key, not its gate value), and `decide` runs the same + `DecisionPolicy` the server runs. + """ + + name = "local" + + def __init__(self, stock_dir: str, ocr: Any = None) -> None: + from identity_lanes import LocalLane, TextLane + + self.lane = LocalLane(stock_dir) + self.text_lane = TextLane.from_catalog( + _catalog_rows(stock_dir), stock_dir, ocr if ocr is not None else _ocr_engine() + ) + + def rank(self, crop: Any) -> List[Tuple[str, float]]: + return self.lane.rank(crop) + + def decide(self, crop: Any, ranked: List[Tuple[str, float]], *, accept: float, + margin: float, scale: str) -> Dict[str, Any]: + from identity_lanes import DecisionPolicy + + text_rank = sorted( + self.text_lane.scores_from_text(self.text_lane.read(crop)).items(), + key=lambda kv: kv[1], reverse=True, + ) + return DecisionPolicy(local_accept=accept).decide(ranked, text_rank) + + +def _catalog_rows(stock_dir: str) -> List[Dict[str, Any]]: + """The catalog's rows, in the shape TextLane.from_catalog expects.""" + from inventory import InventoryManager + + return InventoryManager(stock_dir).products.values() + + +def _ocr_engine() -> Any: + from ocr import build_ocr_engine + + return build_ocr_engine() + + class GalleryLane(Lane): """DINOv2 embedding, with the store's trained head when it has one.""" @@ -263,8 +324,8 @@ class HybridLane(Lane): name = "hybrid-verify" - def __init__(self, shortlist: Lane, verifier: Lane, size: int = 5) -> None: - self.shortlist_lane = shortlist + def __init__(self, shortlist_lane: Lane, verifier: MatcherLane, size: int = 5) -> None: + self.shortlist_lane = shortlist_lane self.verifier = verifier self.size = int(size) self.recall_hits = 0 @@ -322,8 +383,7 @@ def score_lane( if crop.size == 0: continue ranked = lane.rank(crop) - decision = decide_identity(dict(ranked), method=lane.name, scale=scale, - min_score=accept, min_margin=margin) + decision = lane.decide(crop, ranked, accept=accept, margin=margin, scale=scale) # decide_identity reports an accept as status "success"; identity_level # is the stable field across both the inlier and cosine scales. answered = decision.get("best_sku") if decision.get("identity_level") == "accepted" else None @@ -422,7 +482,7 @@ def main() -> int: parser.add_argument("--labels", required=True, help="labelled facings JSON") parser.add_argument("--image", help="shelf photo; defaults to the labels' own image") parser.add_argument("--stock-dir", default="stock-lopia") - parser.add_argument("--lanes", default="matcher,gallery,hybrid-verify") + parser.add_argument("--lanes", default="local,matcher,gallery,hybrid-verify") parser.add_argument("--shortlist", type=int, default=5) parser.add_argument("--accept", type=float, default=None, help="override the lane's own floor") parser.add_argument("--margin", type=float, default=None) @@ -461,11 +521,12 @@ def main() -> int: lanes: Dict[str, Lane] = {} if "matcher" in wanted or "hybrid-verify" in wanted: lanes["matcher"] = MatcherLane(args.stock_dir) + if "local" in wanted: + lanes["local"] = LocalLaneAdapter(args.stock_dir) if "gallery" in wanted or "hybrid-verify" in wanted: lanes["gallery"] = GalleryLane(args.stock_dir, catalog_skus) if "hybrid-verify" in wanted: lanes["hybrid-verify"] = HybridLane(lanes["gallery"], lanes["matcher"], size=args.shortlist) - # Boxes are in the original image's coordinates. Scoring a downscaled copy # needs the boxes scaled with it, or every crop is taken from the wrong place. resolutions = [("full", image, 1.0)] @@ -497,9 +558,11 @@ def main() -> int: rows = [] for name in wanted: lane = lanes[name] - scale = "cosine" if name == "gallery" else "inlier" - accept = args.accept if args.accept is not None else (0.60 if scale == "cosine" else 1.0) - margin = args.margin if args.margin is not None else (0.04 if scale == "cosine" else 0.15) + scale = {"gallery": "cosine", "local": "local"}.get(name, "inlier") + default_accept = {"cosine": 0.60, "local": LOCAL_ACCEPT}.get(scale, 1.0) + default_margin = 0.04 if scale == "cosine" else 0.15 + accept = args.accept if args.accept is not None else default_accept + margin = args.margin if args.margin is not None else default_margin row = score_lane(lane, scaled, picture, accept=accept, margin=margin, scale=scale, enrolled_skus=enrolled_skus) if isinstance(lane, HybridLane): diff --git a/recognization/code/rebuild_catalog.py b/recognization/code/rebuild_catalog.py index 1970a47..f8c9787 100644 --- a/recognization/code/rebuild_catalog.py +++ b/recognization/code/rebuild_catalog.py @@ -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 .jpg, _2.jpg, ... The CSV is carried over whole, so -text-only products stay in the catalog. +Views are named .jpg, _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)