fix(recognition): calibrate the accept lines against unrelated shelves
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.
This commit is contained in:
parent
4ce41ecb17
commit
5094b5f6ca
12 changed files with 815 additions and 195 deletions
|
|
@ -15,6 +15,13 @@ Reported per run: correct / in-catalog facings, precision, unknown accepts, and
|
|||
tracks that matched no labelled facing. A track whose box overlaps a facing the
|
||||
catalog does not enrol is an unenrolled false accept - the dangerous kind, and
|
||||
the one the evaluator's own precision denominator used to hide.
|
||||
|
||||
G1 has two halves - new covers at least as much as Van, and the lower bound of
|
||||
the paired-bootstrap difference clears -2 pp - and the second half needs the
|
||||
per-facing pairing, so every row carries the `facing` index it matched. Two
|
||||
reports plus the labels then answer the gate on the same facings:
|
||||
|
||||
python code/score_stream.py NEW.json --van VAN.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -22,6 +29,7 @@ from __future__ import annotations
|
|||
import argparse
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
|
|
@ -33,6 +41,14 @@ if current_dir not in sys.path:
|
|||
# so the denominator keeps only facings at least this far inside the window.
|
||||
VISIBLE_MIN = 0.5
|
||||
|
||||
# G1's second half: the lower bound of the paired difference must clear -2 pp.
|
||||
G1_BOUND_MIN = -0.02
|
||||
|
||||
# Resampling draws for the paired bootstrap, and the seed that makes the bound
|
||||
# a number this repo can print twice.
|
||||
BOOTSTRAP_DRAWS = 10000
|
||||
BOOTSTRAP_SEED = 0
|
||||
|
||||
|
||||
def iou(a: Sequence[int], b: Sequence[int]) -> float:
|
||||
ax1, ay1, ax2, ay2 = a
|
||||
|
|
@ -72,6 +88,22 @@ def visible_fraction(box: Sequence[int], window: Sequence[int]) -> float:
|
|||
return (inter_w * inter_h) / area if area > 0 else 0.0
|
||||
|
||||
|
||||
def reachable(facings: Sequence[Dict[str, Any]],
|
||||
windows: Optional[Sequence[Sequence[int]]]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Facings a run's windows showed well enough for the run to have named."""
|
||||
if not windows:
|
||||
return list(facings)
|
||||
return [f for f in facings
|
||||
if max(visible_fraction(f["box"], w) for w in windows) >= VISIBLE_MIN]
|
||||
|
||||
|
||||
def correct_facings(rows: Sequence[Dict[str, Any]]) -> set:
|
||||
"""Facing indices at least one track got right - one hit each, not one per track."""
|
||||
return {row["facing"] for row in rows
|
||||
if row["outcome"] == "correct" and row["facing"] is not None}
|
||||
|
||||
|
||||
def score(tracks: Sequence[Dict[str, Any]], facings: Sequence[Dict[str, Any]],
|
||||
stock_dir: str, iou_min: float,
|
||||
windows: Optional[Sequence[Sequence[int]]] = None
|
||||
|
|
@ -90,11 +122,7 @@ def score(tracks: Sequence[Dict[str, Any]], facings: Sequence[Dict[str, Any]],
|
|||
text lane names SKUs the image lane has no view for, which is the point of
|
||||
it, and scoring that as a false accept punishes the lane for working.
|
||||
"""
|
||||
if windows:
|
||||
facings = [
|
||||
f for f in facings
|
||||
if max(visible_fraction(f["box"], w) for w in windows) >= VISIBLE_MIN
|
||||
]
|
||||
facings = reachable(facings, windows)
|
||||
in_catalog = [f for f in facings if f["label"] != "unknown"]
|
||||
enrolled = enrolled_skus(stock_dir)
|
||||
|
||||
|
|
@ -122,7 +150,8 @@ def score(tracks: Sequence[Dict[str, Any]], facings: Sequence[Dict[str, Any]],
|
|||
if best is None or best_iou < iou_min:
|
||||
unmatched += 1
|
||||
rows.append({"track_id": track["track_id"], "predicted": track["sku_id"],
|
||||
"truth": None, "iou": round(best_iou, 3), "outcome": "unmatched"})
|
||||
"facing": None, "truth": None, "iou": round(best_iou, 3),
|
||||
"outcome": "unmatched"})
|
||||
continue
|
||||
truth = best["label"]
|
||||
predicted = track["sku_id"]
|
||||
|
|
@ -140,7 +169,8 @@ def score(tracks: Sequence[Dict[str, Any]], facings: Sequence[Dict[str, Any]],
|
|||
wrong += 1
|
||||
outcome = "wrong"
|
||||
rows.append({"track_id": track["track_id"], "predicted": predicted,
|
||||
"truth": truth, "iou": round(best_iou, 3), "outcome": outcome})
|
||||
"facing": best["index"], "truth": truth,
|
||||
"iou": round(best_iou, 3), "outcome": outcome})
|
||||
|
||||
shown = correct + wrong + unenrolled_false + unknown_false
|
||||
return {
|
||||
|
|
@ -155,10 +185,81 @@ def score(tracks: Sequence[Dict[str, Any]], facings: Sequence[Dict[str, Any]],
|
|||
"shown": shown,
|
||||
"coverage": round(len(named) / len(in_catalog), 4) if in_catalog else 0.0,
|
||||
"precision": round(correct / shown, 4) if shown else 0.0,
|
||||
# The window set travels with the report: a paired comparison has to
|
||||
# know what each run was shown, not just what it named.
|
||||
"windows": [list(w) for w in windows] if windows else None,
|
||||
"rows": rows,
|
||||
}
|
||||
|
||||
|
||||
def paired_bootstrap(van: Dict[str, Any], new: Dict[str, Any],
|
||||
facings: Sequence[Dict[str, Any]],
|
||||
draws: int = BOOTSTRAP_DRAWS,
|
||||
seed: int = BOOTSTRAP_SEED) -> Dict[str, Any]:
|
||||
"""G1's second half: new minus Van, per facing, with the 95 % lower bound.
|
||||
|
||||
Both reports are graded on one facing set - the in-catalog facings either
|
||||
run's windows showed - so a facing one run panned past cannot flatter the
|
||||
other. Resampling draws facings with replacement rather than comparing two
|
||||
independent rates, because the two runs saw the same photo: pairing removes
|
||||
the facing-to-facing variance that otherwise swamps a 27-facing sample.
|
||||
|
||||
Percentile method on the sorted draw differences; the seed is fixed so the
|
||||
number in a report is one this repo reproduces.
|
||||
"""
|
||||
windows = list(van.get("windows") or []) + list(new.get("windows") or [])
|
||||
shared = sorted(f["index"] for f in reachable(facings, windows)
|
||||
if f["label"] != "unknown")
|
||||
if not shared:
|
||||
return {"facings": 0, "difference": 0.0, "lower_95": 0.0, "upper_95": 0.0,
|
||||
"van": 0, "new": 0, "draws": draws, "seed": seed}
|
||||
|
||||
van_hits = correct_facings(van["rows"])
|
||||
new_hits = correct_facings(new["rows"])
|
||||
van_vector = [1 if i in van_hits else 0 for i in shared]
|
||||
new_vector = [1 if i in new_hits else 0 for i in shared]
|
||||
n = len(shared)
|
||||
|
||||
rng = random.Random(seed)
|
||||
spread = []
|
||||
for _ in range(draws):
|
||||
van_sum = new_sum = 0
|
||||
for _ in range(n):
|
||||
# The same draw index into both vectors: that is the pairing.
|
||||
drawn = rng.randrange(n)
|
||||
van_sum += van_vector[drawn]
|
||||
new_sum += new_vector[drawn]
|
||||
spread.append((new_sum - van_sum) / n)
|
||||
spread.sort()
|
||||
tail = max(1, int(0.025 * draws))
|
||||
return {
|
||||
"facings": n,
|
||||
"van": sum(van_vector),
|
||||
"new": sum(new_vector),
|
||||
"van_rate": round(sum(van_vector) / n, 4),
|
||||
"new_rate": round(sum(new_vector) / n, 4),
|
||||
"difference": round((sum(new_vector) - sum(van_vector)) / n, 4),
|
||||
"lower_95": round(spread[tail - 1], 4),
|
||||
"upper_95": round(spread[min(draws - 1, int(0.975 * draws))], 4),
|
||||
"draws": draws,
|
||||
"seed": seed,
|
||||
}
|
||||
|
||||
|
||||
def load_run(path: str, window: Optional[str] = None
|
||||
) -> Tuple[Dict[str, Any], Optional[List[Tuple[int, ...]]]]:
|
||||
"""A stream_client --tracks dump and the source-image windows it showed."""
|
||||
with open(path) as handle:
|
||||
payload = json.load(handle)
|
||||
if window:
|
||||
windows = [tuple(int(part) for part in window.split(","))]
|
||||
elif payload.get("windows"):
|
||||
windows = [tuple(w) for w in payload["windows"]]
|
||||
else:
|
||||
windows = None
|
||||
return payload, windows
|
||||
|
||||
|
||||
def main(argv: Optional[Sequence[str]] = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Score streamed labels against ground truth")
|
||||
parser.add_argument("tracks", help="JSON written by stream_client.py --tracks")
|
||||
|
|
@ -169,22 +270,20 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
|||
parser.add_argument("--window", default=None,
|
||||
help="x0,y0,x1,y1 the client showed; the denominator is "
|
||||
"restricted to facings at least half inside it")
|
||||
parser.add_argument("--van", default=None,
|
||||
help="Van baseline track dump on the same clip; scores G1's "
|
||||
"paired-bootstrap half against it")
|
||||
parser.add_argument("--draws", type=int, default=BOOTSTRAP_DRAWS)
|
||||
parser.add_argument("--seed", type=int, default=BOOTSTRAP_SEED)
|
||||
parser.add_argument("--verbose", action="store_true", help="print every scored track")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
with open(args.tracks) as handle:
|
||||
payload = json.load(handle)
|
||||
windows = None
|
||||
if args.window:
|
||||
windows = [tuple(int(part) for part in args.window.split(","))]
|
||||
elif payload.get("windows"):
|
||||
windows = [tuple(w) for w in payload["windows"]]
|
||||
report = score(payload["tracks"], load_facings(args.labels), args.stock_dir,
|
||||
args.iou, windows)
|
||||
facings = load_facings(args.labels)
|
||||
payload, windows = load_run(args.tracks, args.window)
|
||||
report = score(payload["tracks"], facings, args.stock_dir, args.iou, windows)
|
||||
report["source"] = payload.get("source")
|
||||
report["pace"] = payload.get("pace")
|
||||
report["seconds"] = payload.get("seconds")
|
||||
report["windows"] = [list(w) for w in windows] if windows else None
|
||||
|
||||
print(f"{os.path.basename(args.tracks)} pace={report['pace']} "
|
||||
f"{report['seconds']}s")
|
||||
|
|
@ -197,7 +296,27 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
|||
if args.verbose:
|
||||
for row in report["rows"]:
|
||||
print(f" t{row['track_id']:4d} {row['outcome']:24s} "
|
||||
f"pred={row['predicted']} truth={row['truth']} iou={row['iou']}")
|
||||
f"facing={row['facing']} pred={row['predicted']} "
|
||||
f"truth={row['truth']} iou={row['iou']}")
|
||||
|
||||
if args.van:
|
||||
van_payload, van_windows = load_run(args.van, args.window)
|
||||
van_report = score(van_payload["tracks"], facings, args.stock_dir,
|
||||
args.iou, van_windows)
|
||||
paired = paired_bootstrap(van_report, report, facings, args.draws, args.seed)
|
||||
report["van"] = {k: van_report[k] for k in
|
||||
("in_catalog_facings", "facings_named", "coverage",
|
||||
"correct", "wrong", "precision", "shown")}
|
||||
report["g1_paired"] = paired
|
||||
print(f" G1 vs {os.path.basename(args.van)}: new {paired['new']}/"
|
||||
f"{paired['facings']} ({paired['new_rate']:.0%}) - van {paired['van']}/"
|
||||
f"{paired['facings']} ({paired['van_rate']:.0%}) = "
|
||||
f"{paired['difference'] * 100:+.1f} pp")
|
||||
print(f" paired bootstrap {paired['draws']} draws seed {paired['seed']}: "
|
||||
f"95% bounds {paired['lower_95'] * 100:+.1f} pp to "
|
||||
f"{paired['upper_95'] * 100:+.1f} pp; lower bound clears "
|
||||
f"{G1_BOUND_MIN * 100:+.0f} pp: "
|
||||
f"{'yes' if paired['lower_95'] >= G1_BOUND_MIN else 'NO'}")
|
||||
|
||||
if args.json_path:
|
||||
with open(args.json_path, "w") as handle:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue