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

@ -128,11 +128,10 @@ class FakeInventory:
def __init__(self):
self.products = {
"sku_a": {"sku_id": "sku_a", "product_name": "Product sku_a", "price": 150.0,
"sku_a": {"sku_id": "sku_a", "product_name": "コクうま カロリーカット65%", "price": 150.0,
"number_in": 10, "number_out": 1, "number_on": 9,
"receipt_date": "", "expiration_date": "", "local_image_path": None},
"other": {"sku_id": "other", "product_name": "Product other", "price": 150.0,
"number_in": 10, "number_out": 1, "number_on": 9,
"other": {"sku_id": "other", "product_name": "アマニ油マヨネーズ", "price": 150.0,
"receipt_date": "", "expiration_date": "", "local_image_path": None},
}
@ -266,6 +265,36 @@ class LabelLifecycleTests(unittest.TestCase):
== "recognized")
engine.shutdown()
def test_a_pan_does_not_let_a_neighbour_inherit(self):
"""A label must not slide onto whatever moved into its screen position.
The memory box is stored in the coordinates of the frame it was
measured on. Under a pan the shelf slides beneath that box, so the
product now occupying the old screen position is a different one.
Comparing the raw box hands it the label at IoU 1.0; compensating by
the shelf movement drops the overlap to nothing.
"""
session, engine, detector, matcher = make_session()
for frame_id in range(1, 5):
process(session, frame_id)
self.assertTrue(wait_for(lambda: process(session, 5)["tracks"][0]["identity"]["state"]
== "recognized"))
# The product leaves, so its track ages out and the next detection at
# that screen position is a new object rather than the same one.
detector.boxes = []
for frame_id in range(6, 10):
process(session, frame_id)
# The shelf slides 90 px: the labelled product is now 90 px to the
# left, and the screen position it used to occupy holds its neighbour.
session._frame_origin = [90.0, 0.0]
detector.boxes = [{"id": 0, "box": [100, 100, 200, 260], "confidence": 0.9, "class_id": 0,
"width": 100, "height": 160, "center": [150, 180]}]
result = process(session, 10)
row = result["tracks"][0]
self.assertFalse(row["inherited"],
"the product that slid into the old box is not the labelled one")
engine.shutdown()
class CosineScaleTests(unittest.TestCase):
"""A gallery-scale score is gated by the gallery, not re-gated by the live path."""