Improve identity lanes: text evidence floor, motion-compensated labels

The text lane now requires shared bigrams between the read and the winning
row's name before it will answer, and the local lane passes the catalog
signatures through so the decision can weigh text evidence. On the labeled
shelf this moves the local lane from 66% to 80% end-to-end at full
resolution (91% precision) and 66% at live resolution (95% precision, 2
false accepts), against 57%/35% for the matcher alone.

Live fixes:
- `_plan_classification` counts `in_flight` again. The increment had been
  dropped while removing a `label_frame_id` write, so the guard never
  blocked, every frame re-planned the same tracks, and the executor queue
  grew without bound until the server stopped answering.
- Label memory boxes carry the shelf origin they were measured against and
  are shifted before the IoU test, so a panning camera no longer slides a
  stale box onto its neighbour.
- The text lane is resolved before the session lock is taken, removing a
  lock-ordering hazard against the lane's own build lock.
- A native crop always reads; the frame crop skips OCR only when it already
  has a reading.

Stream (20 s pan, 4 runs): 71-79% coverage at 76-84% precision, against
62-71% at 73-75% before.
This commit is contained in:
tngo 2026-09-29 02:08:23 +09:00
parent 771e9285d7
commit bf9c04bce4
7 changed files with 293 additions and 31 deletions

View file

@ -133,14 +133,40 @@ def normalize_text(text: str) -> str:
return "".join(char for char in value if char.isalnum() or char == "%")
def signature_bigrams(text: str) -> Set[str]:
"""Character pairs of a catalog name, never across a word boundary.
A pair that spans two words is not a substring of either one, so it can
only ever be noise - and expensive noise, because it is rare across the
catalog and IDF therefore weights it as highly distinctive. "キユーピー
アマニ油" used to contribute "ピア", which let the garbage read
"キユピアク5C" be accepted as that row.
"""
pairs: Set[str] = set()
for token in text.split():
value = normalize_text(token)
pairs.update(value[index:index + 2] for index in range(len(value) - 1))
return pairs
def bigrams(text: str) -> Set[str]:
"""Character pairs of a read, across whitespace.
Deliberately not `signature_bigrams`: OCR puts spaces where the package
has none, so "ピュア セレクト" has to keep matching the catalog's
"ピュアセレクト". The catalog name is authoritative about where its words
end; a read is noisy evidence about the same characters, and dropping its
spaces is the only way the two can meet. The read is the side that gets
the benefit of the doubt, so the fake-pair problem is fixed where it
originates - in the signature - rather than by refusing to match.
"""
value = normalize_text(text)
return {value[index:index + 2] for index in range(len(value) - 1)}
def name_ocr_similarity(name: str, ocr_text: str) -> float:
"""Share of the catalog name's bigrams that the OCR text explains."""
name_bigrams = bigrams(name)
name_bigrams = signature_bigrams(name)
return len(name_bigrams & bigrams(ocr_text)) / max(1, len(name_bigrams))