From fc49ba60e66ccd8bde6abb5098f1134e26e599b4 Mon Sep 17 00:00:00 2001 From: tngo Date: Tue, 29 Sep 2026 00:41:52 +0900 Subject: [PATCH] Cache only text reads that could name a product The per-track OCR cache kept any non-empty read, so a first crop that read only a size ("400g") was replayed on every later attempt. A size matches every 400g product's size line, so the track could never be named even after a clean crop arrived: the cached read replaced it. Only a read carrying identity text is cached now, so the next attempt gets a fresh OCR. `carries_identity` moves to module scope in live.py; a function-local import shadowed the name at the cache site and raised UnboundLocalError. --- recognization/code/live.py | 10 +++++++--- recognization/code/test_live.py | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/recognization/code/live.py b/recognization/code/live.py index d4240ac..54e7421 100644 --- a/recognization/code/live.py +++ b/recognization/code/live.py @@ -39,6 +39,7 @@ from engine import ( # noqa: E402 STATE_UNRECOGNIZED, RecognitionEngine, ) +from identity_lanes import carries_identity # noqa: E402 from tracking import SessionTracker # noqa: E402 EMPTY_SLOT_HISTORY = 5 @@ -1183,9 +1184,12 @@ class LiveSession: self._classify_times.append(finished_at - submitted_at) # The reading is cached on the track so later attempts skip OCR. - # An empty read is not cached: a crop that was too small or too - # blurry to read says nothing about the next one. - if text_read: + # Only a read that could name something is cached: a crop that was + # too small or too blurry says nothing about the next one, and a + # read of just a size ("400g") matches every 400g product's size + # line, so caching it would let one unreadable crop veto the + # product for the rest of the track's life. + if text_read and carries_identity(text_read): track.text_rank = list(text_rank or []) track.text_read = text_read elif track.text_read: diff --git a/recognization/code/test_live.py b/recognization/code/test_live.py index 6c6ef92..ecf721e 100644 --- a/recognization/code/test_live.py +++ b/recognization/code/test_live.py @@ -403,6 +403,29 @@ class LocalScaleTests(unittest.TestCase): self.assertEqual(engine.ocr_calls, 1, "OCR must run once per track") engine.shutdown() + def test_a_size_only_read_is_not_cached(self): + """An unreadable crop must not veto the product for the track's life. + + The cache used to keep any non-empty read, so a first crop that read + only "400g" was replayed on every later attempt: the track could never + be named even after a clean crop arrived, because the cached read + replaced it. Only a read that carries identity text is cached now, so + the next attempt gets a fresh OCR. + """ + session, engine, _, _ = make_session(engine_cls=LocalScaleEngine) + engine.lane_answers = [ + ({}, [("sku_a", 0.9)], "400g"), + ({}, [("sku_a", 0.9)], "コクうま 65%"), + ] + [({}, [("sku_a", 0.9)], "コクうま 65%")] * 6 + labelled = False + for frame_id in range(1, 14): + for track in process(session, frame_id)["tracks"]: + if track["identity"]["sku_id"] == "sku_a": + labelled = True + self.assertTrue(labelled, "a later readable crop must still name the track") + self.assertGreater(engine.ocr_calls, 1, "the size-only read must not be cached") + engine.shutdown() + class TagPassIntegrationTests(unittest.TestCase): """The tag pass must reach the live result without blocking frames."""