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:
parent
71b8bdd876
commit
a27841afa4
6 changed files with 407 additions and 27 deletions
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue