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

@ -61,6 +61,7 @@ class LiveSettings:
method: str = "hybrid"
conf: float = 0.1
empty_slots: bool = True
tags: bool = True
min_hits: int = 2
min_crop_px: int = 40
max_attempts: int = 3
@ -176,6 +177,9 @@ class LiveSession:
self._slot_counter = 0
self._catalog_by_sku: Dict[str, Dict[str, Any]] = {}
self._catalog_version = ""
self._tags = None
self._compliance_tracker = None
self._last_compliance: Dict[str, Any] = {"violations": 0, "by_rule": {}, "rows": []}
self._label_memory: "collections.deque[Dict[str, Any]]" = collections.deque(
maxlen=LABEL_MEMORY_MAX
)
@ -227,6 +231,7 @@ class LiveSession:
method: Optional[str] = None,
conf: Optional[float] = None,
empty_slots: Optional[bool] = None,
tags: Optional[bool] = None,
) -> Dict[str, Any]:
with self._lock:
method_changed = method is not None and method != self.settings.method
@ -236,6 +241,8 @@ class LiveSession:
self.settings.conf = float(conf)
if empty_slots is not None:
self.settings.empty_slots = bool(empty_slots)
if tags is not None:
self.settings.tags = bool(tags)
if method_changed:
self._generation += 1
for track in self._tracks.values():
@ -255,6 +262,9 @@ class LiveSession:
"method": self.settings.method,
"conf": self.settings.conf,
"empty_slots": self.settings.empty_slots,
"tags": self.settings.tags,
"tags_available": self._tags is not None,
"ocr_engine": getattr(self._tags.engine, "name", "none") if self._tags else "none",
}
def reset(self) -> None:
@ -380,6 +390,17 @@ class LiveSession:
quality = self._quality(image, self._tracks)
self._inherit_labels(seq)
# Price tags: plan a pass (cheap) under the lock, run it on the tag
# worker, and attach whatever the cache already knows this frame.
tag_bands = []
if self._tags is not None and self.settings.tags:
tag_boxes = [
{"id": state.track_id, "box": state.box}
for state in self._tracks.values()
if state.box
]
tag_bands = self._tags.plan(tag_boxes, image.shape)
# Collect the classification work under the lock; submit it after
# releasing it. add_done_callback can run the callback inline when the
# future already finished, and the callback takes this same lock.
@ -388,8 +409,13 @@ class LiveSession:
summary = self._summary_locked()
stats = self._stats_locked()
track_payload = [self._track_payload(state) for state in self._tracks.values()]
tag_summary = self._apply_tags(seq, track_payload, tag_bands)
if tag_summary is not None:
summary["price_tags"] = tag_summary["tags"]
summary["compliance"] = tag_summary["compliance"]
self._submit_classification(submissions, method, generation)
self._run_tag_passes(image, tag_bands)
result = {
"type": "result",
@ -846,6 +872,87 @@ class LiveSession:
self._catalog_by_sku = {row["sku_id"]: row for row in products}
self._catalog_version = self.engine.catalog_version()
# ------------------------------------------------------------------ price tags
def enable_tags(self, settings: Optional[Any] = None) -> bool:
"""Attach a tag pass when an OCR engine is available. Returns whether it runs."""
ocr_engine = getattr(self.engine, "ocr_engine", None)
if ocr_engine is None or not getattr(ocr_engine, "available", lambda: False)():
return False
from tags import TagService, TagSettings
with self._lock:
self._tags = TagService(ocr_engine, settings=settings or TagSettings())
return True
def _run_tag_passes(self, image: Any, bands: Sequence[Any]) -> None:
"""Submit tag passes to the engine's tag worker (never the frame loop)."""
service = self._tags
if service is None or not bands:
return
for band in bands:
try:
future = self.engine.tag_executor.submit(service.run_pass, image, band)
except RuntimeError: # executor shut down
return
future.add_done_callback(self._on_tag_pass_done)
def _on_tag_pass_done(self, future: Any) -> None:
try:
future.result()
except Exception:
# A failed OCR pass must not disturb the stream; the band simply stays
# unread until the next attempt.
pass
def _apply_tags(
self, seq: int, track_payloads: List[Dict[str, Any]], bands: Sequence[Any]
) -> Optional[Dict[str, Any]]:
"""Attach tag + reconciliation to each track and compute compliance.
Must be called with the session lock held (it reads the tag cache under its
own lock, which is never taken the other way round).
"""
service = self._tags
if service is None or not self.settings.tags:
return None
from reconcile import ComplianceTracker, evaluate_compliance, reconcile, summarize_findings
tag_by_track, all_tags, counts = service.attach(track_payloads)
reconciliations: Dict[int, Dict[str, Any]] = {}
for payload in track_payloads:
track_id = int(payload["track_id"])
tag = tag_by_track.get(track_id)
identity = payload.get("identity")
product = self._catalog_by_sku.get((identity or {}).get("sku_id") or "")
decision = reconcile(identity, tag, product)
if tag is not None:
payload["price_tag"] = tag
if decision is not None:
payload["reconciliation"] = decision
reconciliations[track_id] = decision
if self._compliance_tracker is None:
self._compliance_tracker = ComplianceTracker()
findings = evaluate_compliance(
track_payloads, tag_by_track, all_tags, bands, reconciliations
)
stable = self._compliance_tracker.update(findings)
summary = summarize_findings(stable)
rows = [
{
"row_index": finding.row_index,
"rule": finding.rule,
"box": finding.box,
"missing": finding.count,
"skus": finding.skus,
}
for finding in stable
if finding.severity == "violation"
]
self._last_compliance = {"violations": summary["violations"], "by_rule": summary["by_rule"], "rows": rows,
"hints": summary["hints"]}
return {"tags": counts, "compliance": self._last_compliance}
def snapshot(self) -> Dict[str, Any]:
with self._lock:
return {
@ -855,5 +962,7 @@ class LiveSession:
"seq": self._seq,
"stats": self._stats_locked(),
"quality": dict(self._last_quality),
"tags": self._tags.stats() if self._tags else None,
"compliance": dict(self._last_compliance),
"created_at": self._created_at,
}