fix: count products, not facings, and demand an anchor before accusing

Two false positives came out of running the rules against a real shelf, and both
had the same shape: the rules were arguing with our own detector instead of with
the shelf.

1. One tag per product, not per facing. Measured: a row carries 16-18 facings but
   only 4-5 tags, because four facings of a mayonnaise share a single tag. Counting
   facings invents missing tags on a shelf that is perfectly labelled. The rules
   now group a row's facings into products - by identity when the facings were
   recognised, by size and spacing otherwise - and count products.

2. A product's tag may sit anywhere along its run. Measured: a run of
   ピュアセレクト facings was split by geometry because one facing was detected
   narrower than its neighbours (54 px beside 150 px), and the fragment was then
   reported as missing the tag that was sitting further along its own run. A
   product now counts as missing only when no tag exists for its SKU anywhere in
   the row, and a fragment far narrower than the row's typical product is not
   judged at all.

3. A mismatch needs one trustworthy anchor. Measured noise: a wrong identity plus
   a garbled tag read (name match 0.11, no JAN) produced a confident "tag says 90,
   catalog says 188" that was really two bad reads disagreeing. Without the tag's
   JAN or a name that resembles the product, that is now a hint, not a violation.

Measured on add/image6.jpg through the real still path: 0 false violations without
identities (was 1), and with identities the only finding left is a genuine
tag-versus-catalog price difference. Coverage hints now read "tags detected for 2
of 4 products in this row". 143 tests pass.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
kiyo9w 2026-09-24 22:44:50 +09:00
parent 02c0fd1f4f
commit cf7e8a5a88
2 changed files with 228 additions and 35 deletions

View file

@ -244,6 +244,72 @@ class ComplianceTracker:
self._history.clear()
def _sku_of_track(track: Dict[str, Any]) -> Optional[str]:
identity = track.get("identity") or {}
if identity.get("state") == "recognized" and identity.get("sku_id"):
return str(identity["sku_id"])
return None
def group_facings(
row_tracks: Sequence[Dict[str, Any]],
max_gap_ratio: float = 0.35,
size_tolerance: float = 0.45,
) -> List[List[Dict[str, Any]]]:
"""Group a row's facings into products.
A store prints one price tag per *product*, not per facing: measured on the
demo shelf, a row carries 16-18 facings but only 4-5 tags, because the four
facings of one mayonnaise share a single tag. Counting facings therefore
invents missing tags on a shelf that is perfectly labelled, so the rules count
*groups*.
Identity wins over geometry when both facings were recognised: measured, a
run of ピュアセレクト facings was split by geometry alone because one facing
was detected narrower than its neighbours, and the product was then reported
as missing the tag that was in fact sitting further along its own run.
"""
ordered = sorted(
(track for track in row_tracks if track.get("box") or track.get("raw_box")),
key=lambda track: (track.get("box") or track.get("raw_box"))[0],
)
groups: List[List[Dict[str, Any]]] = []
for track in ordered:
box = track.get("box") or track.get("raw_box")
if groups:
previous = groups[-1][-1]
pbox = previous.get("box") or previous.get("raw_box")
gap = box[0] - pbox[2]
width = max(1, pbox[2] - pbox[0])
height = max(1, pbox[3] - pbox[1])
width2 = max(1, box[2] - box[0])
height2 = max(1, box[3] - box[1])
sku, previous_sku = _sku_of_track(track), _sku_of_track(previous)
close = gap <= max_gap_ratio * width
if sku and previous_sku:
same_product = sku == previous_sku and close
else:
same_product = close and (
abs(height2 - height) / height <= size_tolerance
and abs(width2 - width) / width <= size_tolerance
)
if same_product:
groups[-1].append(track)
continue
groups.append([track])
return groups
def _group_box(group: Sequence[Dict[str, Any]]) -> List[int]:
boxes = [track.get("box") or track.get("raw_box") for track in group]
return [
min(box[0] for box in boxes),
min(box[1] for box in boxes),
max(box[2] for box in boxes),
max(box[3] for box in boxes),
]
def evaluate_compliance(
tracks: Sequence[Dict[str, Any]],
tag_by_track: Dict[int, Dict[str, Any]],
@ -272,7 +338,21 @@ def evaluate_compliance(
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))
# Count products, not facings: one tag serves a run of identical facings.
products = group_facings(row_tracks)
product_tags = [
[
tag_by_track.get(int(track["track_id"]), {})
for track in group
]
for group in products
]
products_evidenced = [
group
for group, tags in zip(products, product_tags)
if any(tag.get("state") in (TAG_STATE_READABLE, TAG_STATE_UNREADABLE) for tag in tags)
]
coverage = len(products_evidenced) / max(1, len(products))
if not readable or coverage < settings.min_row_tag_coverage:
# Fail closed on evidence quality. Either nothing in this row could be
@ -282,7 +362,10 @@ def evaluate_compliance(
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"
else (
f"tags detected for {len(products_evidenced)} of {len(products)} products "
f"in this row"
)
)
findings.append(
Finding(
@ -297,36 +380,60 @@ def evaluate_compliance(
continue
# 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
# the products that lack a tag listed rather than one finding per facing.
# A product's tag may sit anywhere along its run of facings, so a group
# only counts as missing when no tag exists for its SKU elsewhere in the
# row either. This is the domain rule, applied as a guard.
skus_with_tags = {
_sku_of_track(track)
for track, tag in zip(row_tracks, row_tags)
if tag.get("state") == TAG_STATE_ABSENT
if tag.get("state") in (TAG_STATE_READABLE, TAG_STATE_UNREADABLE)
} - {None}
# A fragment of a product is not a product. Measured without identities: a
# facing detected at 54 px wide beside 150 px neighbours was split off and
# reported as missing the tag that belonged to its own run. Anything much
# narrower than the row's typical product is not judged.
widths = sorted(
max(1, (_group_box(group)[2] - _group_box(group)[0])) for group in products
)
typical_width = widths[len(widths) // 2] if widths else 0
missing_groups = [
group
for group, tags in zip(products, product_tags)
if all(tag.get("state") == TAG_STATE_ABSENT for tag in tags)
and not (_sku_of_track(group[0]) in skus_with_tags)
and (_group_box(group)[2] - _group_box(group)[0]) >= 0.6 * typical_width
]
if missing:
if missing_groups:
findings.append(
Finding(
rule=RULE_MISSING_TAG,
severity="violation",
row_index=row_index,
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",
box=_row_box([track for group in missing_groups for track in group]),
count=len(missing_groups),
skus=[_sku_of(group[0]) for group in missing_groups],
detail=(
f"{len(missing_groups)} of {len(products)} products in this row "
f"have no price tag"
),
)
)
# missing_tag_group: consecutive facings without a tag
run: List[Dict[str, Any]] = []
for track, tag in zip(row_tracks, row_tags):
if tag.get("state") == TAG_STATE_ABSENT:
run.append(track)
# missing_tag_group: consecutive products without a tag
missing_ids = {id(group) for group in missing_groups}
run: List[List[Dict[str, Any]]] = []
for group, tags in zip(products, product_tags):
if id(group) in missing_ids:
run.append(group)
continue
if len(run) >= settings.group_min_facings:
findings.append(_group_finding(row_index, run))
findings.append(
_group_finding(row_index, [track for g in run for track in g], len(run))
)
run = []
if len(run) >= settings.group_min_facings:
findings.append(_group_finding(row_index, run))
findings.append(_group_finding(row_index, [track for g in run for track in g], len(run)))
# price_mismatch from reconciliation
for track in tracks:
@ -334,7 +441,33 @@ def evaluate_compliance(
reconciliation = (reconciliations or {}).get(track_id)
if not reconciliation or reconciliation.get("state") != RECONCILED_MISMATCH:
continue
if not any(signal.get("kind") == "price" for signal in reconciliation.get("signals", [])):
signals = reconciliation.get("signals", [])
if not any(signal.get("kind") == "price" for signal in signals):
continue
# A mismatch needs one trustworthy anchor before it is a finding. Measured:
# a wrong identity plus a garbled tag read (name match 0.11, no JAN)
# produced a confident-looking "tag says 90, catalog says 188" that was
# really just noise. With an anchor - the tag's JAN, or a name that
# actually resembles the product - the disagreement is worth reporting.
tag_for_track = tag_by_track.get(track_id, {})
name_signal = next(
(signal for signal in signals if signal.get("kind") == "name"), None
)
has_anchor = bool(tag_for_track.get("jan")) or bool(
name_signal and float(name_signal.get("note", "0").split()[-1] or 0) >= 0.35
)
if not has_anchor:
findings.append(
Finding(
rule=RULE_UNREADABLE_TAGS,
severity="hint",
row_index=_row_for(track.get("box") or track.get("raw_box"), bands),
box=list(track.get("box") or track.get("raw_box") or [0, 0, 0, 0]),
count=1,
skus=[_sku_of(track)],
detail="the tag under this product could not be read well enough to compare",
)
)
continue
findings.append(
Finding(
@ -372,7 +505,7 @@ def evaluate_compliance(
return findings
def _group_finding(row_index: int, run: Sequence[Dict[str, Any]]) -> Finding:
def _group_finding(row_index: int, run: Sequence[Dict[str, Any]], products: int = 0) -> Finding:
boxes = [track.get("box") or track.get("raw_box") or [0, 0, 0, 0] for track in run]
x1 = min(box[0] for box in boxes)
y1 = min(box[1] for box in boxes)
@ -385,7 +518,9 @@ def _group_finding(row_index: int, run: Sequence[Dict[str, Any]]) -> Finding:
box=[x1, y1, x2, y2],
count=len(run),
skus=[_sku_of(track) for track in run],
detail=f"{len(run)} facings in a row without a price tag",
detail=(
f"{products or len(run)} products in a row without a price tag"
),
)