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

@ -45,28 +45,38 @@ from rebuild_catalog import bigrams, normalize_text, signature_bigrams, sku_of
# wrong label on a shelf is worse than a missing one.
#
# 4.0 since 2026-09-29: the line was tuned on image6 alone, where every crop is
# a facing of a product the shelf actually holds. On 2297 crops from unrelated
# Wikimedia shelves, 2.0 accepted 25 of them on geometry alone (scores 2.26-4.81
# with no text overlap at all), a 1.1% far-unknown rate against G3's 1%. At 4.0
# that falls to 8 (0.35%) while image6 keeps 42 of 48 correct labels at 95.5%
# precision, which is the only region passing both G2 and G3 with coverage left.
# a facing of a product the shelf actually holds, so that set could not price
# it. Re-measured with `code/sweep_accept_lines.py` on 4580 crops from 64
# unrelated Wikimedia shelf photos - 1280-long-side frames at
# `LiveSettings.conf`, the configuration the phone runs - against
# `TEXT_MIN_SHARED = 4`: 2.0 accepts 60 of them (1.31%) and 4.0 accepts 3
# (0.07%), against G3's 1%. On image6 at full resolution 4.0 shows 44 labels of
# which 42 are right - 95.5% precision, and 65.6% of the 64 facings that hold a
# catalog row - and it is the highest-coverage cell in the sweep that passes
# both G2 and G3.
LOCAL_ACCEPT = 4.0
# The text lane's own accept line: a bigram overlap this high, with this much
# margin over the runner-up, names the row on its own. Measured: the text lane
# picks the right SKU on 41-42 of the 55 facings where it reads anything.
TEXT_ACCEPT = 0.5
TEXT_MARGIN = 0.15
# How many of the read's bigrams the winning row must actually contain. The
# ratio alone is not evidence: a two-pair read the row happens to contain scores
# 1.0, and "サソース" named sku_l08 that way. Two pairs was the old floor, and it
# is too low once the lane answers on unrelated shelves: on 2297 crops from
# Wikimedia milk, soy and snack shelves, 20 of 26 text-lane answers rested on
# exactly two shared bigrams ("避くて おいしい" -> sku_l12 via おい/いし), an
# 11.4% false accept rate against G3's 1%. Four is where the two sides separate
# once the local line is also at 4.0: false accepts fall to 8 (0.35%) while
# image6 keeps 42 of its 48 correct labels. It is a floor on evidence, not on
# the ratio, so a long read of shared brand copy still scores low and is still
# refused by the margin.
# How many of the read's bigrams the winning row must actually contain before
# that read may name a product. A floor on the amount of shared evidence, not
# on the ratio: a two-pair read the row happens to contain scores 1.0, and
# "サソース" named sku_l08 that way.
#
# It gates the text lane's *answer* and nothing else. Zeroing the row's score
# would be a different policy under the same name: it reorders the ranking, so
# a zeroed top row promotes a runner-up and the margin gate ends up comparing a
# different pair than the one it was measured on. It would also gate the veto,
# which is backwards - the veto only ever refuses a local accept, so a floor
# under it would let a two-bigram read rescue a match the local lane earned.
# `code/sweep_accept_lines.py` measures the line by calling the policy below
# with this value, so the sweep and the shipped lane cannot disagree about what
# it means.
#
# The ratio stays the ranking's own gate, so a long read of shared brand copy
# still scores low and is still refused by the margin.
TEXT_MIN_SHARED = 4
# Reference pyramid heights. A ~1000 px packshot against a 110-250 px crop is a
# 4-8x ratio, and ORB's 8x1.2 pyramid tops out at 3.6x, which is why ORB and
@ -348,8 +358,15 @@ def text_similarity(
the most real text to match were punished hardest. Bounded this way the
same reads score 0.86 and 1.0 against their true rows, and the garbage
read ties across three rows and is refused by the margin.
`vocabulary` defaults to the weight table's own keys, which are by
construction the bigrams some signature produces. A second copy of that set
on the lane is a second thing to keep in step, and it is the drift the
module already paid for once with the folding rules.
"""
read_grams = bigrams(read)
if vocabulary is None and weights is not None:
vocabulary = set(weights)
if vocabulary is not None:
read_grams &= vocabulary
if not read_grams:
@ -404,12 +421,6 @@ class TextLane:
# Bigram weights over the catalog's own names, so the lane can tell
# copy every sibling prints from the words that name one product.
self.weights = bigram_weights(list(self.signatures.values()))
# Every pair any name can produce. A read's pairs outside this set are
# OCR noise or word-boundary artifacts, and counting them in the
# denominator charges a row for text it could never have explained.
self.vocabulary: Set[str] = set()
for text in self.signatures.values():
self.vocabulary |= signature_bigrams(text)
self.ocr_engine = ocr_engine
# The engine's OCR lock. Vision tolerates concurrency but the CPU does
# not, and this lane runs one OCR per crop on the classify pool, so it
@ -472,7 +483,7 @@ class TextLane:
if len(normalize_text(read)) < 4:
return {}
return {
sku: text_similarity(read, signature, self.weights, self.vocabulary)
sku: text_similarity(read, signature, self.weights)
for sku, signature in self.signatures.items()
}