feat: price-tag UI, still-image tag passes, and honest tag evidence

Web client: price-tag boxes in a distinct style (teal, chip with the parsed
price), an OCR section in the product card showing the tag's price, name,
discount and the reconciliation verdict with both values, and a compliance strip
under the stage. The photo stage now draws the image into the canvas and uses the
same containment as the media, so boxes cannot drift from the product (they did:
the canvas stretched to the stage while the photo was contained).

Server fixes the browser runs forced out:
- Photo mode reconciled before identities existed, so every tag read "tag only".
  The tag bundle is cached with the still and classify re-reconciles against the
  identities it just produced, returning them per detection.
- The still path only ever read the first four bands (plan hands out two per
  call); it now drains every band, twice at different upscales so the vote has
  independent reads.
- Voting is confidence-weighted and a dead heat stays unread; the plausible
  shelf-price range is 10-9999, which stops a misread from becoming a ¥26,300
  price. The compliance coverage bar moved to 0.7 after 0.5 produced a false
  "group without price tag" on a shelf where every facing has one.

Measured on add/image6.jpg: 8 readable tags with names and prices, 942 ms for
the whole still, 7 real price mismatches, no false violations, and hints that
say plainly when a row could not be read.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
kiyo9w 2026-09-24 14:11:23 +09:00
parent a27841afa4
commit 8f750c15c5
8 changed files with 330 additions and 60 deletions

View file

@ -189,7 +189,9 @@ class ComplianceSettings:
# 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
# 0.7 measured: at 0.5 a row where our own tag detection was patchy produced a
# false "group without price tag" on a shelf where every facing has a tag.
min_row_tag_coverage: float = 0.7
@dataclass
@ -434,12 +436,23 @@ def attach_tags_to_boxes(
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)
# A still has no later frame to pick up the remaining bands, and `plan` only
# hands out a few per call (a stream must stay responsive). Drain them, with a
# cap so a huge shelf cannot turn one request into minutes of OCR. Draining
# also feeds the vote: a band read in two rounds is two observations.
bands: List[Any] = []
rounds = 0
while rounds < 4:
planned = tag_service.plan(boxes, image_shape)
if not planned:
break
for band in planned:
# Two independent reads (different upscale) so the vote has evidence
# to work with; a single read has nothing to vote against.
tag_service.run_pass(image, band, scale_bias=1.0)
tag_service.run_pass(image, band, scale_bias=1.7)
bands.append(band)
rounds += 1
tracks = [
{
@ -468,6 +481,61 @@ def attach_tags_to_boxes(
"counts": counts,
"reconciliations": reconciliations,
"bands": [{"row_index": band.row_index, "box": band.box} for band in bands],
"bands_objects": list(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 reconcile_bundle(
bundle: Dict[str, Any],
detections: Sequence[Dict[str, Any]],
catalog_by_sku: Dict[str, Dict[str, Any]],
) -> Dict[str, Any]:
"""Re-run reconciliation and compliance against a cached tag bundle.
Used when identities arrive after the tag pass (photo mode: recognize paints
boxes, classify names them). Without this, every tag would look like `tag_only`
because it was compared against an identity that did not exist yet.
"""
tracks = [
{
"track_id": int(detection["id"]),
"box": list(detection["box"]),
"identity": detection.get("identity") or {},
}
for detection in detections
]
tag_by_track = bundle.get("by_box") or {}
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, bundle.get("tags") or [], bundle.get("bands_objects") or [], reconciliations
)
summary = summarize_findings(findings)
return {
"reconciliations": reconciliations,
"compliance": {
"violations": summary["violations"],
"by_rule": summary["by_rule"],