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

@ -418,11 +418,16 @@ class LocalLaneAdapter(Lane):
self.lane = LocalLane(stock_dir)
# E-3 asks what the text lane is worth, which is only answerable by
# running the same crops without it. Building the lane anyway keeps the
# comparison to one variable: the OCR cost is paid either way.
# running the same crops without it. `--no-text` therefore drops the
# lane whole: nothing in the decision reads it, and an ablation that
# still had to build an OCR engine could not be run on a machine
# without one.
self.use_text = use_text
self.text_lane = TextLane.from_catalog(
_catalog_rows(stock_dir), stock_dir, ocr if ocr is not None else _ocr_engine()
self.text_lane = (
TextLane.from_catalog(
_catalog_rows(stock_dir), stock_dir, ocr if ocr is not None else _ocr_engine()
)
if use_text else None
)
def rank(self, crop: Any) -> List[Tuple[str, float]]:
@ -433,9 +438,10 @@ class LocalLaneAdapter(Lane):
from identity_lanes import DecisionPolicy
if not self.use_text:
from identity import decide_identity
return decide_identity(dict(ranked), scale="local", min_score=accept)
# E-3's other arm: the same policy, over the same ranking, with no
# text evidence. `dict(ranked)` would not do - it drops the lane's
# order, and the value is not the order (see `LocalLane.rank`).
return DecisionPolicy(local_accept=accept).decide(ranked)
read = self.text_lane.read(crop)
text_rank = sorted(
self.text_lane.scores_from_text(read).items(),
@ -523,8 +529,6 @@ def score_lane(
the SKU is on the shelf but has no reference, so an accept there is wrong
and is the failure the enrollment protocol exists to prevent.
"""
from identity import decide_identity
in_catalog = [f for f in facings if f["label"] != UNKNOWN]
out_catalog = [f for f in facings if f["label"] == UNKNOWN]
enrolled_count = sum(1 for f in in_catalog if f["label"] in enrolled_skus)