feat: run the tag pass in parallel with recognition, in live and still modes

The engine owns the OCR backend, a shared OCR lock and a dedicated tag worker, so
a tag pass can never occupy a classification worker or slow a frame. A live
session plans rail bands on the frame loop (cheap), runs them on that worker, and
attaches whatever the cache already knows; a still runs one synchronous pass.
Tracks and detections now carry price_tag and reconciliation, and the summary
carries price_tags plus compliance. Config can turn tags off per session.

Compliance now fails closed on evidence quality. The first real run produced 106
"missing tag" violations on a shelf where only 8 tags were readable - our tag
*detection* was incomplete, not the shelf. A row is only judged when at least
half its facings carry tag evidence; otherwise it reports "tags detected for 14
of 91 facings in this row" as a hint. Missing-tag findings are one per row with a
count, not one per facing, and price_mismatch still reports every disagreement.
On add/image6.jpg the result is now 7 real price mismatches and no false
violations.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
kiyo9w 2026-09-24 13:58:18 +09:00
parent 71b8bdd876
commit a27841afa4
6 changed files with 407 additions and 27 deletions

View file

@ -193,6 +193,88 @@ class LabelLifecycleTests(unittest.TestCase):
engine.shutdown()
class TagPassIntegrationTests(unittest.TestCase):
"""The tag pass must reach the live result without blocking frames."""
def make_tagged_session(self):
from ocr import FakeOcr, OcrLine
def reader(crop):
height, width = crop.shape[:2]
return [
OcrLine("コーヒー", 0.8, [int(width * 0.05), int(height * 0.05), int(width * 0.4), int(height * 0.3)]),
OcrLine("150", 0.9, [int(width * 0.05), int(height * 0.4), int(width * 0.35), int(height * 0.8)]),
]
session, engine, detector, matcher = make_session()
# Rail bands come from product rows, so the shelf needs a row of facings.
detector.boxes = [
{"id": index, "box": [100 + index * 120, 100, 200 + index * 120, 260],
"confidence": 0.9, "class_id": 0, "width": 100, "height": 160,
"center": [150 + index * 120, 180]}
for index in range(3)
]
engine.set_ocr_engine(FakeOcr(reader=reader))
self.assertTrue(session.enable_tags())
return session, engine, detector
def test_track_gains_a_price_tag_and_a_reconciliation(self):
import time as _time
session, engine, detector = self.make_tagged_session()
result = None
for frame_id in range(1, 12):
result = process(session, frame_id)
_time.sleep(0.05) # the tag pass runs on its own worker
if any(
(track.get("price_tag") or {}).get("state") == "readable"
for track in result["tracks"]
):
break
track = result["tracks"][0]
self.assertIn("price_tag", track, "the tag pass should attach to the facing above it")
self.assertEqual(track["price_tag"]["price"], 150)
self.assertIn("reconciliation", track)
self.assertEqual(result["summary"]["price_tags"]["readable"], 1)
engine.shutdown()
def test_frames_are_not_blocked_by_ocr(self):
"""A slow OCR engine must not slow the frame loop: frames keep coming."""
import time as _time
from ocr import FakeOcr, OcrLine
def slow_reader(crop):
_time.sleep(0.5)
height, width = crop.shape[:2]
return [OcrLine("150", 0.9, [10, 10, int(width * 0.3), int(height * 0.6)])]
session, engine, detector, _ = make_session()
detector.boxes = [
{"id": index, "box": [100 + index * 120, 100, 200 + index * 120, 260],
"confidence": 0.9, "class_id": 0, "width": 100, "height": 160,
"center": [150 + index * 120, 180]}
for index in range(3)
]
engine.set_ocr_engine(FakeOcr(reader=slow_reader))
self.assertTrue(session.enable_tags())
started = _time.perf_counter()
for frame_id in range(1, 4):
process(session, frame_id)
elapsed = _time.perf_counter() - started
self.assertLess(elapsed, 1.0, "three frames must not wait for a 0.5 s OCR pass")
engine.shutdown()
def test_config_can_turn_tags_off(self):
session, engine, _ = self.make_tagged_session()
config = session.configure(tags=False)
self.assertFalse(config["tags"])
self.assertTrue(config["tags_available"])
result = process(session, 1)
self.assertNotIn("price_tag", result["tracks"][0])
engine.shutdown()
class FramePipelineTests(unittest.TestCase):
def test_latest_frame_wins_and_drops_are_counted(self):
session, engine, _, _ = make_session()