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:
kiyo9w 2026-09-29 09:28:13 +09:00
parent 4ce41ecb17
commit 5094b5f6ca
12 changed files with 815 additions and 195 deletions

View file

@ -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: