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

@ -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: