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
|
|
@ -186,6 +186,10 @@ class ComplianceSettings:
|
|||
group_min_facings: int = GROUP_MIN_FACINGS
|
||||
stability_frames: int = STABILITY_FRAMES
|
||||
stability_window: int = STABILITY_WINDOW
|
||||
# A row may only be judged when enough of its facings carry tag evidence
|
||||
# (readable or unreadable). Below this, the row is "not evaluated": claiming a
|
||||
# tag is missing because our detector found none would be a false violation.
|
||||
min_row_tag_coverage: float = 0.5
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -263,10 +267,21 @@ def evaluate_compliance(
|
|||
row_tracks.sort(key=lambda track: (track.get("box") or [0])[0])
|
||||
row_tags = [tag_by_track.get(int(track["track_id"]), {}) for track in row_tracks]
|
||||
readable = [tag for tag in row_tags if tag.get("state") == TAG_STATE_READABLE]
|
||||
if not readable:
|
||||
# Fail closed: nothing in this row could be read, so nothing can be
|
||||
# claimed about missing tags here. Report the capture problem instead.
|
||||
if any(tag.get("state") == TAG_STATE_UNREADABLE for tag in row_tags):
|
||||
evidenced = [
|
||||
tag for tag in row_tags if tag.get("state") in (TAG_STATE_READABLE, TAG_STATE_UNREADABLE)
|
||||
]
|
||||
coverage = len(evidenced) / max(1, len(row_tracks))
|
||||
|
||||
if not readable or coverage < settings.min_row_tag_coverage:
|
||||
# Fail closed on evidence quality. Either nothing in this row could be
|
||||
# read, or too few facings carry any tag evidence at all - in both cases
|
||||
# the honest answer is "not evaluated", never "tag missing".
|
||||
if evidenced:
|
||||
detail = (
|
||||
"move closer: the price tags in this row could not be read"
|
||||
if not readable
|
||||
else f"tags detected for {len(evidenced)} of {len(row_tracks)} facings in this row"
|
||||
)
|
||||
findings.append(
|
||||
Finding(
|
||||
rule=RULE_UNREADABLE_TAGS,
|
||||
|
|
@ -274,25 +289,28 @@ def evaluate_compliance(
|
|||
row_index=row_index,
|
||||
box=_row_box(row_tracks),
|
||||
count=len(row_tracks),
|
||||
detail="move closer: the price tags in this row could not be read",
|
||||
detail=detail,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
# missing_price_tag, per facing
|
||||
missing: List[Dict[str, Any]] = []
|
||||
for track, tag in zip(row_tracks, row_tags):
|
||||
if tag.get("state") == TAG_STATE_ABSENT:
|
||||
missing.append(track)
|
||||
for track in missing:
|
||||
# missing_price_tag: one finding per row (the UI highlights the row), with
|
||||
# the facings that lack a tag listed rather than one finding per facing.
|
||||
missing = [
|
||||
track
|
||||
for track, tag in zip(row_tracks, row_tags)
|
||||
if tag.get("state") == TAG_STATE_ABSENT
|
||||
]
|
||||
if missing:
|
||||
findings.append(
|
||||
Finding(
|
||||
rule=RULE_MISSING_TAG,
|
||||
severity="violation",
|
||||
row_index=row_index,
|
||||
box=list(track.get("box") or track.get("raw_box") or [0, 0, 0, 0]),
|
||||
count=1,
|
||||
skus=[_sku_of(track)],
|
||||
box=_row_box(missing),
|
||||
count=len(missing),
|
||||
skus=[_sku_of(track) for track in missing],
|
||||
detail=f"{len(missing)} of {len(row_tracks)} facings in this row have no price tag",
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -403,6 +421,72 @@ def _sku_of(track: Dict[str, Any]) -> str:
|
|||
return str(identity.get("sku_id") or "")
|
||||
|
||||
|
||||
def attach_tags_to_boxes(
|
||||
tag_service: Any,
|
||||
image: Any,
|
||||
boxes: Sequence[Dict[str, Any]],
|
||||
catalog_by_sku: Dict[str, Dict[str, Any]],
|
||||
identities: Optional[Dict[int, Dict[str, Any]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""One-shot tag pass for a still image (photo mode / `?tags=true`).
|
||||
|
||||
Runs the passes synchronously - a still is not a stream, so there is no frame
|
||||
loop to protect - then attaches tags, reconciles, and evaluates compliance.
|
||||
"""
|
||||
image_shape = image.shape
|
||||
bands = tag_service.plan(boxes, image_shape)
|
||||
for band in bands:
|
||||
tag_service.run_pass(image, band)
|
||||
# Plan again: a still has no later frame to pick up a second band.
|
||||
for band in tag_service.plan(boxes, image_shape):
|
||||
tag_service.run_pass(image, band)
|
||||
|
||||
tracks = [
|
||||
{
|
||||
"track_id": int(box["id"]),
|
||||
"box": list(box["box"]),
|
||||
"identity": (identities or {}).get(int(box["id"])) or {},
|
||||
}
|
||||
for box in boxes
|
||||
]
|
||||
tag_by_track, all_tags, counts = tag_service.attach(tracks)
|
||||
|
||||
reconciliations: Dict[int, Dict[str, Any]] = {}
|
||||
for track in tracks:
|
||||
track_id = track["track_id"]
|
||||
tag = tag_by_track.get(track_id)
|
||||
product = catalog_by_sku.get((track["identity"] or {}).get("sku_id") or "")
|
||||
decision = reconcile(track["identity"], tag, product)
|
||||
if decision is not None:
|
||||
reconciliations[track_id] = decision
|
||||
|
||||
findings = evaluate_compliance(tracks, tag_by_track, all_tags, bands, reconciliations)
|
||||
summary = summarize_findings(findings)
|
||||
return {
|
||||
"by_box": tag_by_track,
|
||||
"tags": all_tags,
|
||||
"counts": counts,
|
||||
"reconciliations": reconciliations,
|
||||
"bands": [{"row_index": band.row_index, "box": band.box} for band in bands],
|
||||
"compliance": {
|
||||
"violations": summary["violations"],
|
||||
"by_rule": summary["by_rule"],
|
||||
"rows": [
|
||||
{
|
||||
"row_index": finding.row_index,
|
||||
"rule": finding.rule,
|
||||
"box": finding.box,
|
||||
"missing": finding.count,
|
||||
"skus": finding.skus,
|
||||
}
|
||||
for finding in findings
|
||||
if finding.severity == "violation"
|
||||
],
|
||||
"hints": summary["hints"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def summarize_findings(findings: Sequence[Finding]) -> Dict[str, Any]:
|
||||
by_rule: Dict[str, int] = {}
|
||||
for finding in findings:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue