Every threshold was fitted on image6, where each crop is a facing of a product the shelf holds, so the set could not price a false accept. Measured on 4580 crops from 64 unrelated Wikimedia shelf photos at the shipped configuration: LOCAL_ACCEPT 2.0 -> 4.0 false accepts 60 (1.31%) -> 3 (0.07%) TEXT_MIN_SHARED 2 -> 4 the two-bigram reads that named sku_l12 via おい/いし On image6 that is 44 shown / 42 correct at full resolution - 95.5% precision, 65.6% coverage - the highest-coverage cell of the joint sweep that passes both G2 (>= 95%) and G3 (<= 1%). At live resolution 56% end-to-end at 97%. The text lane's evidence floor gates its answer only: zeroing the row's score would reorder the ranking and make the margin gate compare a different pair than the one it was measured on. code/sweep_accept_lines.py sweeps both axes jointly through the shipped DecisionPolicy; the earlier table swept one axis while pinning the other, so its baseline rows were not the old line.
225 lines
9.2 KiB
Python
225 lines
9.2 KiB
Python
"""Joint sweep of the two open-set gates: LOCAL_ACCEPT x TEXT_MIN_SHARED.
|
|
|
|
Both lines were fitted on image6, where every crop is a facing of a product the
|
|
shelf actually holds. That set can only measure false accepts on the products it
|
|
happens to contain, so it cannot price a line on its own - the far-unknown rate
|
|
comes from `data/external-shelves/*`: Wikimedia Commons shelf photos that share
|
|
nothing with the catalog, where every accept is a false accept by construction.
|
|
|
|
Each cell is produced by calling the shipped `DecisionPolicy` with that cell's
|
|
values and the catalog signatures, never by re-implementing the gate in the
|
|
sweep: a sweep that scores its own copy of the policy reports on a machine the
|
|
server does not run. `TEXT_MIN_SHARED` is a floor on the evidence behind the
|
|
text lane's *answer*. It is deliberately not a floor on the score - zeroing a
|
|
row reorders the ranking, so the margin gate would compare a different pair -
|
|
and deliberately not a floor on the veto, which only ever refuses. The constant
|
|
in `identity_lanes.py` says the same thing next to the code that does it.
|
|
|
|
The distractor side is measured the way the phone measures it - a 1280-long-side
|
|
frame at `LiveSettings.conf`, the path `code/eval_distractors.py` runs. The
|
|
shelf is scored at both resolutions: the full-resolution column is the one the
|
|
published table quotes, the live column is what the phone sees.
|
|
|
|
RootSIFT and OCR dominate the runtime and neither depends on the gates, so the
|
|
per-crop evidence is cached. Without `--cache` the sweep rebuilds it every run
|
|
and prints the same numbers, only slower.
|
|
|
|
.venv/bin/python code/sweep_accept_lines.py \\
|
|
--cache data/evaluations/2026-09-29-session/scratch/accept_line_cache.pkl
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import glob
|
|
import os
|
|
import pickle
|
|
import sys
|
|
from typing import Any, Dict, List, Optional, Sequence, 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
|
|
|
|
from eval_identity import ( # noqa: E402
|
|
EXCLUDE,
|
|
LIVE_LONG_SIDE,
|
|
UNKNOWN,
|
|
LocalLaneAdapter,
|
|
at_live_resolution,
|
|
crop_of,
|
|
load_labels,
|
|
)
|
|
from identity_lanes import LOCAL_ACCEPT, TEXT_MIN_SHARED, DecisionPolicy # noqa: E402
|
|
|
|
SHELF_LABELS = "eval/labels/image6.json"
|
|
SHELF_IMAGE = "add/image6.jpg"
|
|
DISTRACTOR_DIRS = (
|
|
"data/external-shelves/wikimedia-commons",
|
|
"data/external-shelves/wikimedia-commons-2",
|
|
)
|
|
LOCAL_ACCEPTS = (2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0)
|
|
TEXT_MIN_SHAREDS = (2, 3, 4, 5, 6)
|
|
CACHE_VERSION = 3
|
|
|
|
# One crop's evidence: the local lane's ranking and the text lane's raw read.
|
|
Evidence = Tuple[Any, str]
|
|
# One facing: its ground-truth label plus that evidence.
|
|
Facing = Tuple[str, Any, str]
|
|
|
|
|
|
def _evidence(lane: LocalLaneAdapter, frame: Any, box: Sequence[int]) -> Optional[Evidence]:
|
|
"""Rank and read one crop, or None when the box is off the frame."""
|
|
crop = crop_of(frame, box)
|
|
if crop.size == 0:
|
|
return None
|
|
return lane.rank(crop), lane.text_lane.read(crop)
|
|
|
|
|
|
def build_cache(stock_dir: str) -> Dict[str, Any]:
|
|
"""Every crop's evidence, gathered once and swept many times."""
|
|
from engine import EngineSettings, build_default_engine
|
|
from live import LiveSettings
|
|
|
|
lane = LocalLaneAdapter(stock_dir)
|
|
image = cv2.imread(SHELF_IMAGE)
|
|
live = at_live_resolution(image)
|
|
factor = live.shape[1] / float(image.shape[1])
|
|
|
|
shelf_full: List[Facing] = []
|
|
shelf_live: List[Facing] = []
|
|
for facing in load_labels(SHELF_LABELS)["facings"]:
|
|
label = facing.get("label")
|
|
if label in (None, EXCLUDE):
|
|
continue
|
|
full = _evidence(lane, image, facing["box"])
|
|
if full is None:
|
|
continue
|
|
shelf_full.append((label, full[0], full[1]))
|
|
# A facing the live frame cuts to nothing is dropped rather than
|
|
# scored at full resolution: the two columns have to be the same crops
|
|
# at two sizes, or the resolution is not the only variable.
|
|
small = _evidence(lane, live, [int(round(v * factor)) for v in facing["box"]])
|
|
if small is not None:
|
|
shelf_live.append((label, small[0], small[1]))
|
|
|
|
engine = build_default_engine(EngineSettings(stock_dir=stock_dir))
|
|
conf = LiveSettings().conf
|
|
distractors: List[Evidence] = []
|
|
photos = 0
|
|
for directory in DISTRACTOR_DIRS:
|
|
paths = sorted(
|
|
path for path in glob.glob(os.path.join(directory, "*"))
|
|
if os.path.splitext(path)[1].lower() in (".jpg", ".jpeg", ".png")
|
|
)
|
|
for path in paths:
|
|
shelf_photo = cv2.imread(path)
|
|
if shelf_photo is None:
|
|
continue
|
|
photos += 1
|
|
frame = at_live_resolution(shelf_photo)
|
|
for detection in engine.detect(frame, conf=conf):
|
|
box = detection.get("box") or detection.get("bbox")
|
|
if not box:
|
|
continue
|
|
item = _evidence(lane, frame, [int(v) for v in box])
|
|
if item is not None:
|
|
distractors.append(item)
|
|
return {
|
|
"version": CACHE_VERSION,
|
|
"conf": conf,
|
|
"photos": photos,
|
|
"shelf_full": shelf_full,
|
|
"shelf_live": shelf_live,
|
|
"distractors": distractors,
|
|
}
|
|
|
|
|
|
def _score(lane: LocalLaneAdapter, policy: DecisionPolicy, facings: Sequence[Facing]) -> Tuple[int, int]:
|
|
"""Shown and correct labels for one shelf column under one policy."""
|
|
shown = correct = 0
|
|
for label, ranked, read in facings:
|
|
text = sorted(
|
|
lane.text_lane.scores_from_text(read).items(), key=lambda kv: kv[1], reverse=True
|
|
)
|
|
answer = policy.decide(ranked, text, text_read=read)["best_sku"]
|
|
if answer:
|
|
shown += 1
|
|
correct += answer == label
|
|
return shown, correct
|
|
|
|
|
|
def _false_accepts(lane: LocalLaneAdapter, policy: DecisionPolicy,
|
|
distractors: Sequence[Evidence]) -> int:
|
|
"""Every accept on an unrelated shelf is a false accept by construction."""
|
|
accepts = 0
|
|
for ranked, read in distractors:
|
|
text = sorted(
|
|
lane.text_lane.scores_from_text(read).items(), key=lambda kv: kv[1], reverse=True
|
|
)
|
|
if policy.decide(ranked, text, text_read=read)["best_sku"]:
|
|
accepts += 1
|
|
return accepts
|
|
|
|
|
|
def main(argv: Optional[List[str]] = None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--stock", default="stock-lopia-clean")
|
|
parser.add_argument("--cache", default="", help="build once, reuse on later runs")
|
|
parser.add_argument("--rebuild", action="store_true", help="ignore an existing cache")
|
|
args = parser.parse_args(argv)
|
|
|
|
cache: Optional[Dict[str, Any]] = None
|
|
if args.cache and os.path.exists(args.cache) and not args.rebuild:
|
|
with open(args.cache, "rb") as handle:
|
|
cache = pickle.load(handle)
|
|
if cache.get("version") != CACHE_VERSION:
|
|
cache = None
|
|
if cache is None:
|
|
cache = build_cache(args.stock)
|
|
if args.cache:
|
|
os.makedirs(os.path.dirname(os.path.abspath(args.cache)), exist_ok=True)
|
|
with open(args.cache, "wb") as handle:
|
|
pickle.dump(cache, handle)
|
|
|
|
lane = LocalLaneAdapter(args.stock)
|
|
shelf_full: List[Facing] = cache["shelf_full"]
|
|
shelf_live: List[Facing] = cache["shelf_live"]
|
|
distractors: List[Evidence] = cache["distractors"]
|
|
known = sum(1 for label, _, _ in shelf_full if label != UNKNOWN)
|
|
|
|
print(f"catalog {args.stock}, {LIVE_LONG_SIDE}-long-side frames at detect_conf {cache['conf']}")
|
|
print(f"image6: {len(shelf_full)} facings, {known} holding a catalog row, "
|
|
f"{len(shelf_live)} of them cut from the live frame")
|
|
print(f"distractors: {len(distractors)} crops from {cache['photos']} unrelated shelf photos")
|
|
print()
|
|
print(f"{'accept':>6} {'floor':>5} | {'shown':>5} {'corr':>4} {'prec':>6} {'cover':>6} "
|
|
f"| {'shown':>5} {'corr':>4} {'prec':>6} | {'false':>5} {'rate':>6}")
|
|
print(f"{'':>6} {'':>5} | {'--- full resolution ---':^25} "
|
|
f"| {'---- live resolution ----':^25} | {'-- unknown shelves --':^14}")
|
|
for accept in LOCAL_ACCEPTS:
|
|
for floor in TEXT_MIN_SHAREDS:
|
|
policy = DecisionPolicy(
|
|
local_accept=accept, text_min_shared=floor,
|
|
signatures=lane.text_lane.signatures,
|
|
)
|
|
full_shown, full_correct = _score(lane, policy, shelf_full)
|
|
live_shown, live_correct = _score(lane, policy, shelf_live)
|
|
false = _false_accepts(lane, policy, distractors)
|
|
shown, correct = full_shown, full_correct
|
|
rate = false / len(distractors) * 100
|
|
shipped = " <- shipped" if (accept, floor) == (LOCAL_ACCEPT, TEXT_MIN_SHARED) else ""
|
|
print(
|
|
f"{accept:>6.1f} {floor:>5} | {shown:>5} {correct:>4} "
|
|
f"{correct / shown * 100 if shown else 0:>5.1f}% {correct / known * 100:>5.1f}% "
|
|
f"| {live_shown:>5} {live_correct:>4} "
|
|
f"{live_correct / live_shown * 100 if live_shown else 0:>5.1f}% "
|
|
f"| {false:>5} {rate:>5.2f}%{shipped}"
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|