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

@ -147,11 +147,11 @@ def track(track_id, box, sku="sku_003", state="recognized"):
}
BANDS = [band(1, [0, 300, 500, 340]), band(2, [0, 600, 500, 640])]
BANDS = [band(1, [0, 300, 700, 340]), band(2, [0, 600, 700, 640])]
class ComplianceTests(unittest.TestCase):
def test_missing_tag_on_a_readable_row_is_a_violation(self):
def test_missing_tag_on_a_covered_row_is_a_violation(self):
tracks = [
track(1, [10, 100, 90, 300]),
track(2, [110, 100, 190, 300]),
@ -163,6 +163,19 @@ class ComplianceTests(unittest.TestCase):
findings = evaluate_compliance(tracks, tags, [tags[1]], BANDS)
rules = [finding.rule for finding in findings]
self.assertIn(RULE_MISSING_TAG, rules)
missing = [finding for finding in findings if finding.rule == RULE_MISSING_TAG][0]
self.assertEqual(missing.count, 1, "one finding per row, counting the facings")
def test_a_row_with_poor_tag_coverage_is_not_evaluated(self):
"""The real failure mode: our detector found tags for only a few of many
facings. Claiming the rest are missing would be a false violation."""
tracks = [track(index, [10 + index * 100, 100, 90 + index * 100, 300]) for index in range(6)]
tags = {index: tag(state=TAG_STATE_ABSENT) for index in range(6)}
tags[0] = tag(state=TAG_STATE_READABLE, price=168, id="tag_a")
findings = evaluate_compliance(tracks, tags, [tags[0]], BANDS)
self.assertEqual([finding.rule for finding in findings], [RULE_UNREADABLE_TAGS])
self.assertEqual(findings[0].severity, "hint")
self.assertEqual(summarize_findings(findings)["violations"], 0)
def test_unreadable_row_yields_a_hint_never_a_violation(self):
"""The demo image case: tags exist but cannot be read. Nothing may be claimed."""
@ -188,15 +201,18 @@ class ComplianceTests(unittest.TestCase):
self.assertEqual(findings, [])
def test_group_of_consecutive_facings_without_tags(self):
tracks = [track(index, [10 + index * 100, 100, 90 + index * 100, 300]) for index in range(5)]
# 5 facings: 2 tagged, 3 not. Coverage counts facings with *any* tag
# evidence, so give the row enough evidence to be judged at all.
tracks = [track(index, [10 + index * 100, 100, 90 + index * 100, 300]) for index in range(6)]
tags = {
0: tag(state=TAG_STATE_READABLE, price=168, id="tag_a"),
1: tag(state=TAG_STATE_ABSENT),
1: tag(state=TAG_STATE_READABLE, price=178, id="tag_b"),
2: tag(state=TAG_STATE_ABSENT),
3: tag(state=TAG_STATE_ABSENT),
4: tag(state=TAG_STATE_READABLE, price=178, id="tag_b"),
4: tag(state=TAG_STATE_ABSENT),
5: tag(state=TAG_STATE_READABLE, price=188, id="tag_c"),
}
findings = evaluate_compliance(tracks, tags, [tags[0], tags[4]], BANDS)
findings = evaluate_compliance(tracks, tags, [tags[0], tags[1], tags[5]], BANDS)
groups = [finding for finding in findings if finding.rule == RULE_MISSING_TAG_GROUP]
self.assertEqual(len(groups), 1)
self.assertEqual(groups[0].count, 3)
@ -208,13 +224,13 @@ class ComplianceTests(unittest.TestCase):
tags = {
0: tag(state=TAG_STATE_READABLE, price=168, id="tag_a"),
1: tag(state=TAG_STATE_ABSENT),
2: tag(state=TAG_STATE_ABSENT),
2: tag(state=TAG_STATE_READABLE, price=188, id="tag_b"),
}
findings = evaluate_compliance(tracks, tags, [tags[0]], BANDS)
findings = evaluate_compliance(tracks, tags, [tags[0], tags[2]], BANDS)
self.assertNotIn(RULE_MISSING_TAG_GROUP, [finding.rule for finding in findings])
self.assertEqual(
len([finding for finding in findings if finding.rule == RULE_MISSING_TAG]), 2
)
missing = [finding for finding in findings if finding.rule == RULE_MISSING_TAG]
self.assertEqual(len(missing), 1)
self.assertEqual(missing[0].count, 1)
def test_orphan_tag(self):
tracks = [track(1, [10, 100, 90, 300])]