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:
parent
aaed5d388f
commit
ff1ed6ebc5
2 changed files with 92 additions and 16 deletions
|
|
@ -50,6 +50,8 @@ import numpy as np # noqa: E402
|
||||||
|
|
||||||
from rebuild_catalog import sku_of # noqa: E402
|
from rebuild_catalog import sku_of # noqa: E402
|
||||||
|
|
||||||
|
from identity import LOCAL_ACCEPT # noqa: E402
|
||||||
|
|
||||||
UNKNOWN = "unknown"
|
UNKNOWN = "unknown"
|
||||||
# Two independent label passes disagreed here, so the row is not ground truth.
|
# Two independent label passes disagreed here, so the row is not ground truth.
|
||||||
# Scoring it would be scoring a coin flip.
|
# Scoring it would be scoring a coin flip.
|
||||||
|
|
@ -223,6 +225,19 @@ class Lane:
|
||||||
"""Candidate SKUs the verifier is allowed to consider."""
|
"""Candidate SKUs the verifier is allowed to consider."""
|
||||||
return [sku for sku, _ in self.rank(crop)[:size]]
|
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):
|
class MatcherLane(Lane):
|
||||||
"""The original demo: local features and RANSAC over the whole catalog."""
|
"""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)
|
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):
|
class GalleryLane(Lane):
|
||||||
"""DINOv2 embedding, with the store's trained head when it has one."""
|
"""DINOv2 embedding, with the store's trained head when it has one."""
|
||||||
|
|
||||||
|
|
@ -263,8 +324,8 @@ class HybridLane(Lane):
|
||||||
|
|
||||||
name = "hybrid-verify"
|
name = "hybrid-verify"
|
||||||
|
|
||||||
def __init__(self, shortlist: Lane, verifier: Lane, size: int = 5) -> None:
|
def __init__(self, shortlist_lane: Lane, verifier: MatcherLane, size: int = 5) -> None:
|
||||||
self.shortlist_lane = shortlist
|
self.shortlist_lane = shortlist_lane
|
||||||
self.verifier = verifier
|
self.verifier = verifier
|
||||||
self.size = int(size)
|
self.size = int(size)
|
||||||
self.recall_hits = 0
|
self.recall_hits = 0
|
||||||
|
|
@ -322,8 +383,7 @@ def score_lane(
|
||||||
if crop.size == 0:
|
if crop.size == 0:
|
||||||
continue
|
continue
|
||||||
ranked = lane.rank(crop)
|
ranked = lane.rank(crop)
|
||||||
decision = decide_identity(dict(ranked), method=lane.name, scale=scale,
|
decision = lane.decide(crop, ranked, accept=accept, margin=margin, scale=scale)
|
||||||
min_score=accept, min_margin=margin)
|
|
||||||
# decide_identity reports an accept as status "success"; identity_level
|
# decide_identity reports an accept as status "success"; identity_level
|
||||||
# is the stable field across both the inlier and cosine scales.
|
# is the stable field across both the inlier and cosine scales.
|
||||||
answered = decision.get("best_sku") if decision.get("identity_level") == "accepted" else None
|
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("--labels", required=True, help="labelled facings JSON")
|
||||||
parser.add_argument("--image", help="shelf photo; defaults to the labels' own image")
|
parser.add_argument("--image", help="shelf photo; defaults to the labels' own image")
|
||||||
parser.add_argument("--stock-dir", default="stock-lopia")
|
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("--shortlist", type=int, default=5)
|
||||||
parser.add_argument("--accept", type=float, default=None, help="override the lane's own floor")
|
parser.add_argument("--accept", type=float, default=None, help="override the lane's own floor")
|
||||||
parser.add_argument("--margin", type=float, default=None)
|
parser.add_argument("--margin", type=float, default=None)
|
||||||
|
|
@ -461,11 +521,12 @@ def main() -> int:
|
||||||
lanes: Dict[str, Lane] = {}
|
lanes: Dict[str, Lane] = {}
|
||||||
if "matcher" in wanted or "hybrid-verify" in wanted:
|
if "matcher" in wanted or "hybrid-verify" in wanted:
|
||||||
lanes["matcher"] = MatcherLane(args.stock_dir)
|
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:
|
if "gallery" in wanted or "hybrid-verify" in wanted:
|
||||||
lanes["gallery"] = GalleryLane(args.stock_dir, catalog_skus)
|
lanes["gallery"] = GalleryLane(args.stock_dir, catalog_skus)
|
||||||
if "hybrid-verify" in wanted:
|
if "hybrid-verify" in wanted:
|
||||||
lanes["hybrid-verify"] = HybridLane(lanes["gallery"], lanes["matcher"], size=args.shortlist)
|
lanes["hybrid-verify"] = HybridLane(lanes["gallery"], lanes["matcher"], size=args.shortlist)
|
||||||
|
|
||||||
# Boxes are in the original image's coordinates. Scoring a downscaled copy
|
# 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.
|
# needs the boxes scaled with it, or every crop is taken from the wrong place.
|
||||||
resolutions = [("full", image, 1.0)]
|
resolutions = [("full", image, 1.0)]
|
||||||
|
|
@ -497,9 +558,11 @@ def main() -> int:
|
||||||
rows = []
|
rows = []
|
||||||
for name in wanted:
|
for name in wanted:
|
||||||
lane = lanes[name]
|
lane = lanes[name]
|
||||||
scale = "cosine" if name == "gallery" else "inlier"
|
scale = {"gallery": "cosine", "local": "local"}.get(name, "inlier")
|
||||||
accept = args.accept if args.accept is not None else (0.60 if scale == "cosine" else 1.0)
|
default_accept = {"cosine": 0.60, "local": LOCAL_ACCEPT}.get(scale, 1.0)
|
||||||
margin = args.margin if args.margin is not None else (0.04 if scale == "cosine" else 0.15)
|
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,
|
row = score_lane(lane, scaled, picture, accept=accept, margin=margin, scale=scale,
|
||||||
enrolled_skus=enrolled_skus)
|
enrolled_skus=enrolled_skus)
|
||||||
if isinstance(lane, HybridLane):
|
if isinstance(lane, HybridLane):
|
||||||
|
|
|
||||||
|
|
@ -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 \
|
python code/rebuild_catalog.py --source /tmp/lopia-clean \
|
||||||
--output stock-lopia-clean --csv stock-lopia/stock.csv --replace
|
--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
|
Views are named <sku>.jpg, <sku>_2.jpg, ... The CSV is copied byte-for-byte, so
|
||||||
text-only products stay in the catalog.
|
text-only products stay in the catalog and its content hash does not move.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
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:
|
for name in views:
|
||||||
shutil.copy2(os.path.join(source, name), os.path.join(staging, name))
|
shutil.copy2(os.path.join(source, name), os.path.join(staging, name))
|
||||||
if csv_path:
|
if csv_path:
|
||||||
fieldnames, rows, _ = read_catalog(csv_path)
|
# Byte-for-byte, not re-serialized: gallery.catalog_digest hashes the
|
||||||
with open(os.path.join(staging, "stock.csv"), "w", newline="", encoding="utf-8") as handle:
|
# raw file, and csv.DictWriter's default CRLF would move the digest
|
||||||
writer = csv.DictWriter(handle, fieldnames=fieldnames)
|
# and invalidate a trained identity head over a newline.
|
||||||
writer.writeheader()
|
shutil.copy2(csv_path, os.path.join(staging, "stock.csv"))
|
||||||
writer.writerows(rows)
|
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):
|
if os.path.exists(output):
|
||||||
shutil.rmtree(output)
|
shutil.rmtree(output)
|
||||||
os.replace(staging, output)
|
os.replace(staging, output)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue