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

@ -224,6 +224,13 @@ class RecognitionEngine:
self._owns_executor = owns_executor
self._detect_lock = threading.Lock()
self._inventory_lock = threading.Lock()
# OCR is the slowest stage (0.2-3.4 s). It gets its own executor so tag
# passes can never occupy a classification worker, and a lock so several
# live sessions do not pile OCR requests onto the CPU at once.
self.ocr_engine: Optional[Any] = None
self.ocr_lock = threading.Lock()
self._tag_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="tags")
self._owns_tag_executor = True
self.image_store = ImageStore(
self.settings.image_store_max,
self.settings.image_store_ttl_s,
@ -237,6 +244,22 @@ class RecognitionEngine:
def executor(self) -> ThreadPoolExecutor:
return self._executor
@property
def tag_executor(self) -> ThreadPoolExecutor:
return self._tag_executor
def set_ocr_engine(self, engine: Any) -> None:
"""Attach an OCR backend; None disables tag reading."""
self.ocr_engine = engine
def read_tags(self, image: Any) -> Any:
"""OCR one crop under the shared lock (Vision is happy with concurrency,
but the CPU is not: one OCR at a time keeps detection responsive)."""
if self.ocr_engine is None:
return None
with self.ocr_lock:
return self.ocr_engine.read(image)
def warm_up(self) -> float:
"""One dummy predict so the first real frame is not the slow one."""
import numpy as np
@ -249,6 +272,8 @@ class RecognitionEngine:
def shutdown(self) -> None:
if self._owns_executor:
self._executor.shutdown(wait=False, cancel_futures=True)
if self._owns_tag_executor:
self._tag_executor.shutdown(wait=False, cancel_futures=True)
# ------------------------------------------------------------------ images
def decode_image(self, payload: bytes) -> Any:
@ -670,7 +695,18 @@ def build_default_engine(settings: Optional[EngineSettings] = None) -> Recogniti
)
matcher = ProductFeatureMatcher(stock_dir=settings.stock_dir)
inventory = InventoryManager(stock_dir=settings.stock_dir, csv_filename=settings.stock_csv)
return RecognitionEngine(detector, matcher, inventory, settings=settings)
engine = RecognitionEngine(detector, matcher, inventory, settings=settings)
from ocr import build_ocr_engine
try:
ocr_engine = build_ocr_engine()
except Exception as exc: # a broken OCR install must not stop recognition
print(f"[Engine] OCR disabled: {exc}")
ocr_engine = None
engine.set_ocr_engine(ocr_engine)
print(f"[Engine] OCR engine: {getattr(ocr_engine, 'name', 'none')}")
return engine
def settings_from_env(env: Optional[Dict[str, str]] = None) -> EngineSettings: