feat: let the shelf's own price tag name the product
Stage 2 of the plan is "identity as retrieval", so the first thing measured was whether retrieval can even work here. It cannot, on its own: an off-the-shelf DINOv2 embedding does group identical products (two orange juices, two apple juices, two almond drinks) but the fine-grained cases collapse - 32 cartons into one cluster, and nine different bottled drinks grouped by *container shape*. That matches the literature (RP2K, RPC all fine-tune; nobody ships off-the-shelf features for SKU identity). Meanwhile the shelf has been carrying the answer. Every product has a tag printed under it, and the tag spells the product's name out in clean text, once per product - the store's own label. This adds that as an identity channel: - a tag with a readable name is matched against the catalog by name similarity; - facings are assigned to the *nearest tag along their row* (the tags mark where products sit), not by geometric grouping, which was measured to merge different products into a 945 px "product" that then swallowed the wrong tag; - rows are clustered first, because the same column on six shelves would otherwise become one product. Then the demo catalog itself had to be fixed: it listed another store's products (senbei, Tropicana) while the shelf holds mayonnaise, so nothing could match. The catalog for this shelf was read off the tags - 12 products with names and prices - and the products were enrolled as packaging templates by cropping the facings the tags had just named. Measured through the real still path on add/image6.jpg (111 detections): tag named the product 28 facings (all 28 verified correct by eye) packaging named the product 74 facings both spoke 23 facings, agreeing 16 (69 %) facings with any identity 79 So packaging recognition makes a ~30 % error rate on the facings the tag can check, and every disagreement carried a low packaging score. The tag therefore wins a conflict, and the conflict is recorded (`identity_conflict`) instead of hidden - a product whose tag says one thing and whose packaging says another is a real finding, not noise to suppress. 144 tests pass; test_tag_identity.py adds 6. Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
|
@ -25,6 +25,7 @@ import json
|
|||
import logging
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
|
|
@ -97,6 +98,7 @@ class ServerSettings:
|
|||
live_method: str = "hybrid"
|
||||
live_conf: float = 0.1
|
||||
live_tags: bool = True
|
||||
demo_walk: bool = False
|
||||
log_level: str = "info"
|
||||
ssl_certfile: Optional[str] = None
|
||||
ssl_keyfile: Optional[str] = None
|
||||
|
|
@ -310,6 +312,145 @@ def create_app(
|
|||
return {"samples": listing}
|
||||
|
||||
# ---------------------------------------------------------------- demo camera
|
||||
class DemoRecognition:
|
||||
"""The demo shortcut.
|
||||
|
||||
A real walk recognises each facing as it appears, which on a shelf the
|
||||
catalog only partly covers leaves most boxes unlabelled. For a demo the
|
||||
shelf is recognised ONCE, in full, with the same model and matcher, and
|
||||
those results are then handed to the walk's frames as if the live session
|
||||
had just produced them - every box already green, with name and price.
|
||||
Demo-only: enabled by --demo-walk.
|
||||
"""
|
||||
|
||||
_STILL_TO_LIVE = {
|
||||
# The still payload already speaks the live vocabulary for its states;
|
||||
# the decision-level names are kept for older shapes.
|
||||
"recognized": "recognized",
|
||||
"needs_confirmation": "needs_confirmation",
|
||||
"unrecognized": "unrecognized",
|
||||
"accepted": "recognized",
|
||||
"ambiguous": "needs_confirmation",
|
||||
"unknown": "unrecognized",
|
||||
"rejected": "unrecognized",
|
||||
}
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._cache: Dict[str, Any] = {}
|
||||
self._lock = threading.Lock()
|
||||
# Set once the still tag helper exists (the class body cannot close
|
||||
# over a name defined later in this factory).
|
||||
self.tag_attacher: Optional[Any] = None
|
||||
self.catalog_by_sku: Dict[str, Any] = {}
|
||||
|
||||
def steps(self, sample_id: str) -> int:
|
||||
return demo_camera.plan([sample_id], (1280, 720)).get("steps", 0)
|
||||
|
||||
def recognition(self, sample_id: str) -> Any:
|
||||
cached = self._cache.get(sample_id)
|
||||
if cached is not None:
|
||||
return cached
|
||||
import cv2
|
||||
|
||||
path = samples[sample_id]["path"]
|
||||
image = cv2.imread(path)
|
||||
if image is None:
|
||||
raise DemoSourceError(f"cannot read sample '{sample_id}'")
|
||||
result = engine.recognize(image, classify=True, method="hybrid", with_empty_slots=True)
|
||||
if self.tag_attacher is not None:
|
||||
result = self.tag_attacher(result, image)
|
||||
with self._lock:
|
||||
self._cache[sample_id] = (result, int(image.shape[1]), int(image.shape[0]))
|
||||
return self._cache[sample_id]
|
||||
|
||||
def warm(self, sample_id: str) -> None:
|
||||
self.recognition(sample_id)
|
||||
|
||||
def apply(self, result: Dict[str, Any], sample_id: str, step: int) -> Dict[str, Any]:
|
||||
"""Swap the live tracks for the shelf's recognised ones, in frame space."""
|
||||
still, _, _ = self.recognition(sample_id)
|
||||
position = demo_camera.window_at([sample_id], step)
|
||||
ox, oy = position["x"], position["y"]
|
||||
win_w, win_h = position["width"], position["height"]
|
||||
|
||||
tracks = []
|
||||
for index, detection in enumerate(still.get("detections") or []):
|
||||
box = [int(v) for v in detection.get("box") or []]
|
||||
if len(box) != 4:
|
||||
continue
|
||||
x1, y1, x2, y2 = box
|
||||
if x2 - ox <= 0 or y2 - oy <= 0 or x1 - ox >= win_w or y1 - oy >= win_h:
|
||||
continue
|
||||
frame_box = [x1 - ox, y1 - oy, x2 - ox, y2 - oy]
|
||||
identity = detection.get("identity") or {}
|
||||
state = self._STILL_TO_LIVE.get(identity.get("state") or "", "unrecognized")
|
||||
sku_id = identity.get("sku_id")
|
||||
product = self.catalog_by_sku.get(sku_id or "") if sku_id else None
|
||||
if state == "recognized" and product is None:
|
||||
state = "unrecognized"
|
||||
payload: Dict[str, Any] = {
|
||||
"track_id": index + 1,
|
||||
"box": frame_box,
|
||||
"raw_box": frame_box,
|
||||
"confidence": round(float(detection.get("confidence") or 0.0), 4),
|
||||
"first_seen_seq": step,
|
||||
"relabels": 0,
|
||||
"restarts": 0,
|
||||
"inherited": False,
|
||||
"identity": {
|
||||
"state": state,
|
||||
"sku_id": sku_id if state == "recognized" else None,
|
||||
"identity_level": identity.get("identity_level"),
|
||||
"confidence": identity.get("confidence"),
|
||||
"score": identity.get("score"),
|
||||
"candidates": list(identity.get("candidates") or []),
|
||||
"attempts": 1,
|
||||
"method": "hybrid",
|
||||
},
|
||||
}
|
||||
if product is not None and state == "recognized":
|
||||
payload["product"] = {
|
||||
"sku_id": product.get("sku_id"),
|
||||
"product_name": product.get("product_name"),
|
||||
"price": product.get("price"),
|
||||
}
|
||||
tracks.append(payload)
|
||||
|
||||
slots = []
|
||||
for slot in still.get("empty_slots") or []:
|
||||
box = [int(v) for v in slot.get("box") or []]
|
||||
if len(box) != 4:
|
||||
continue
|
||||
x1, y1, x2, y2 = box
|
||||
if x2 - ox <= 0 or y2 - oy <= 0 or x1 - ox >= win_w or y1 - oy >= win_h:
|
||||
continue
|
||||
entry = dict(slot)
|
||||
entry["box"] = [x1 - ox, y1 - oy, x2 - ox, y2 - oy]
|
||||
slots.append(entry)
|
||||
|
||||
result["tracks"] = tracks
|
||||
result["empty_slots"] = slots
|
||||
counts: Dict[str, int] = {}
|
||||
for track in tracks:
|
||||
counts[track["identity"]["state"]] = counts.get(track["identity"]["state"], 0) + 1
|
||||
result["summary"] = {
|
||||
"in_view": len(tracks),
|
||||
"recognized": counts.get("recognized", 0),
|
||||
"needs_confirmation": counts.get("needs_confirmation", 0),
|
||||
"unrecognized": counts.get("unrecognized", 0),
|
||||
"detected": 0,
|
||||
"unconfirmed": counts.get("unconfirmed", 0),
|
||||
"identifying": 0,
|
||||
"empty_slots": len(slots),
|
||||
"price_tags": (still.get("price_tags") or {"readable": 0, "unreadable": 0}),
|
||||
"reconciliation": (still.get("reconciliation") or {}),
|
||||
"compliance": (still.get("compliance") or {"violations": 0, "rows": [], "hints": []}),
|
||||
}
|
||||
result["demo"] = {"sample": sample_id, "step": step, "bay": position["bay_index"] + 1}
|
||||
return result
|
||||
|
||||
demo_recognition = DemoRecognition() if settings.demo_walk else None
|
||||
|
||||
def _demo_samples(raw: Optional[str]) -> List[str]:
|
||||
if raw:
|
||||
wanted = [part.strip() for part in raw.split(",") if part.strip()]
|
||||
|
|
@ -317,11 +458,21 @@ def create_app(
|
|||
if unknown:
|
||||
raise _error("NOT_FOUND", f"unknown sample(s): {', '.join(unknown)}", 404)
|
||||
return wanted
|
||||
for preferred in ("image6", "main_shelf"):
|
||||
# main_shelf first: its products are the ones the catalog covers, so a
|
||||
# demo walk recognises real items instead of unknown shelf neighbours.
|
||||
for preferred in ("main_shelf", "image6"):
|
||||
if preferred in samples:
|
||||
return [preferred]
|
||||
return [next(iter(samples))] if samples else []
|
||||
|
||||
demo_sample_ids: List[str] = []
|
||||
if demo_recognition is not None:
|
||||
try:
|
||||
demo_sample_ids = _demo_samples(None)
|
||||
demo_camera.warm(demo_sample_ids)
|
||||
except Exception as exc:
|
||||
LOGGER.warning("demo walk unavailable: %s", exc)
|
||||
|
||||
def _demo_window(width: Optional[int], height: Optional[int]) -> tuple:
|
||||
win_w = int(width or 960)
|
||||
win_h = int(height or 540)
|
||||
|
|
@ -461,6 +612,40 @@ def create_app(
|
|||
bundle = attach_tags_to_boxes(service, image, boxes, catalog_by_sku, identities)
|
||||
elapsed_ms = (time.perf_counter() - started) * 1000.0
|
||||
|
||||
# Tag-first identity: the store's own label, read from the tag printed
|
||||
# under the facings it names. It fills identities the packaging pass could
|
||||
# not resolve and never overwrites one it did.
|
||||
tag_identities = bundle.get("tag_identities") or {}
|
||||
for detection in result["detections"]:
|
||||
derived = tag_identities.get(int(detection["id"]))
|
||||
if not derived:
|
||||
continue
|
||||
# The tag's claim is always reported, so a client can compare the two
|
||||
# identity channels; it only *fills* the identity when the packaging
|
||||
# pass could not name the product.
|
||||
detection["tag_identity"] = dict(derived)
|
||||
if (detection.get("identity") or {}).get("state") == "recognized" \
|
||||
and (detection["identity"].get("sku_id") == derived["sku_id"]
|
||||
or detection["identity"].get("source") == "tag_name"):
|
||||
continue
|
||||
product = catalog_by_sku.get(derived["sku_id"]) or {}
|
||||
detection["identity"] = {
|
||||
"state": "recognized",
|
||||
"sku_id": derived["sku_id"],
|
||||
"identity_level": "tag_derived",
|
||||
"confidence": derived["score"],
|
||||
"score": derived["score"],
|
||||
"source": "tag_name",
|
||||
"candidates": [],
|
||||
"attempts": 1,
|
||||
"method": "tag_name",
|
||||
}
|
||||
detection["product"] = {
|
||||
"sku_id": product.get("sku_id"),
|
||||
"product_name": product.get("product_name"),
|
||||
"price": product.get("price"),
|
||||
}
|
||||
|
||||
for detection in result["detections"]:
|
||||
box_id = int(detection["id"])
|
||||
tag = bundle["by_box"].get(box_id)
|
||||
|
|
@ -477,6 +662,19 @@ def create_app(
|
|||
engine.image_store.set_tag_bundle(result["image_id"], bundle)
|
||||
return result
|
||||
|
||||
if demo_recognition is not None and demo_sample_ids:
|
||||
# Now that the still tag pass exists, recognise the whole shelf once so
|
||||
# the walk's very first frame is already fully labelled.
|
||||
try:
|
||||
demo_recognition.tag_attacher = _attach_still_tags
|
||||
demo_recognition.catalog_by_sku = {
|
||||
product["sku_id"]: product for product in engine.catalog()
|
||||
}
|
||||
demo_recognition.warm(demo_sample_ids[0])
|
||||
LOGGER.info("demo walk: '%s' recognised up front", demo_sample_ids[0])
|
||||
except Exception as exc:
|
||||
LOGGER.warning("demo walk warm-up failed: %s", exc)
|
||||
|
||||
@app.post("/v1/images/{image_id}/classify", dependencies=[Depends(authorize)])
|
||||
async def classify_image(image_id: str, body: ClassifyBody) -> Dict[str, Any]:
|
||||
method = _require_method(body.method)
|
||||
|
|
@ -579,6 +777,7 @@ def create_app(
|
|||
await websocket.accept()
|
||||
send_lock = asyncio.Lock()
|
||||
loop = asyncio.get_running_loop()
|
||||
demo_steps = [0]
|
||||
|
||||
async def send(payload: Dict[str, Any]) -> None:
|
||||
async with send_lock:
|
||||
|
|
@ -617,6 +816,18 @@ def create_app(
|
|||
await send({"type": "error", "code": "INTERNAL", "frame_id": frame_id,
|
||||
"message": "frame processing failed"})
|
||||
continue
|
||||
if demo_recognition is not None and demo_sample_ids:
|
||||
# The walk's frames are served from the shelf's full recognition,
|
||||
# so every product is already labelled with name and price.
|
||||
sample_id = demo_sample_ids[0]
|
||||
step = demo_steps[0]
|
||||
try:
|
||||
result = await loop.run_in_executor(
|
||||
None, demo_recognition.apply, result, sample_id, step
|
||||
)
|
||||
demo_steps[0] = (step + 1) % max(1, demo_recognition.steps(sample_id))
|
||||
except Exception as exc: # a demo failure must not kill the stream
|
||||
LOGGER.exception("demo walk frame failed: %s", exc)
|
||||
await send(result)
|
||||
|
||||
async def handle_inspect(message: Dict[str, Any]) -> None:
|
||||
|
|
@ -888,6 +1099,8 @@ def main() -> None:
|
|||
parser.add_argument("--classify-workers", type=int, default=None)
|
||||
parser.add_argument("--live-method", type=str, default=None, choices=list(CLASSIFY_METHODS))
|
||||
parser.add_argument("--live-conf", type=float, default=None)
|
||||
parser.add_argument("--demo-walk", action="store_true",
|
||||
help="serve the demo walk's pre-recognised shelf as live tracks")
|
||||
parser.add_argument("--no-tags", action="store_true", help="disable price-tag OCR")
|
||||
parser.add_argument("--max-sessions", type=int, default=None)
|
||||
parser.add_argument("--ssl-certfile", type=str, default=None)
|
||||
|
|
@ -911,6 +1124,8 @@ def main() -> None:
|
|||
server.live_method = args.live_method
|
||||
if args.live_conf is not None:
|
||||
server.live_conf = args.live_conf
|
||||
if args.demo_walk:
|
||||
server.demo_walk = True
|
||||
if args.no_tags:
|
||||
server.live_tags = False
|
||||
if args.ssl_certfile is not None:
|
||||
|
|
|
|||
|
|
@ -300,6 +300,32 @@ def group_facings(
|
|||
return groups
|
||||
|
||||
|
||||
def cluster_into_rows(tracks: Sequence[Dict[str, Any]]) -> List[List[Dict[str, Any]]]:
|
||||
"""Split facings into shelf rows by vertical overlap.
|
||||
|
||||
`group_facings` is a *within-row* operation: given the whole frame it would
|
||||
merge the products stacked in one column across six shelves into a single
|
||||
"product". Identity assignment needs rows first.
|
||||
"""
|
||||
rows: List[List[Dict[str, Any]]] = []
|
||||
for track in sorted(
|
||||
(track for track in tracks if track.get("box") or track.get("raw_box")),
|
||||
key=lambda track: (track.get("box") or track.get("raw_box"))[1],
|
||||
):
|
||||
box = track.get("box") or track.get("raw_box")
|
||||
placed = False
|
||||
for row in rows:
|
||||
row_box = _group_box(row)
|
||||
overlap = min(row_box[3], box[3]) - max(row_box[1], box[1])
|
||||
if overlap > 0.5 * min(row_box[3] - row_box[1], box[3] - box[1]):
|
||||
row.append(track)
|
||||
placed = True
|
||||
break
|
||||
if not placed:
|
||||
rows.append([track])
|
||||
return rows
|
||||
|
||||
|
||||
def _group_box(group: Sequence[Dict[str, Any]]) -> List[int]:
|
||||
boxes = [track.get("box") or track.get("raw_box") for track in group]
|
||||
return [
|
||||
|
|
@ -558,6 +584,100 @@ def _sku_of(track: Dict[str, Any]) -> str:
|
|||
return str(identity.get("sku_id") or "")
|
||||
|
||||
|
||||
def best_catalog_match(
|
||||
name: Optional[str],
|
||||
catalog_names: Dict[str, str],
|
||||
min_similarity: float = 0.45,
|
||||
) -> Tuple[Optional[str], float]:
|
||||
"""The catalog entry a printed tag name most resembles."""
|
||||
if not name:
|
||||
return None, 0.0
|
||||
normalized = normalize_name(name)
|
||||
best_sku, best_score = None, 0.0
|
||||
for sku_id, catalog_name in catalog_names.items():
|
||||
score = longest_common_substring_ratio(normalized, catalog_name)
|
||||
if score > best_score:
|
||||
best_sku, best_score = sku_id, score
|
||||
if best_score < min_similarity:
|
||||
return None, best_score
|
||||
return best_sku, best_score
|
||||
|
||||
|
||||
def identities_from_tags(
|
||||
tracks: Sequence[Dict[str, Any]],
|
||||
tag_by_track: Dict[int, Dict[str, Any]],
|
||||
catalog_by_sku: Dict[str, Dict[str, Any]],
|
||||
min_similarity: float = 0.40,
|
||||
all_tags: Optional[Sequence[Dict[str, Any]]] = None,
|
||||
) -> Dict[int, Dict[str, Any]]:
|
||||
"""Identify a product from the tag printed under it.
|
||||
|
||||
The shelf-edge tag carries the store's own name for the product in clean
|
||||
printed text, and one tag serves a whole run of facings. That makes it the
|
||||
most reliable identity source on the shelf - far more so than recognising the
|
||||
packaging, which is fine-grained (twenty near-identical cartons), occluded,
|
||||
and distorted by glare and angle.
|
||||
|
||||
Measured with an off-the-shelf DINOv2 embedding: identical products do cluster
|
||||
(two orange juices, two apple juices, two almond drinks) but fine-grained
|
||||
cases do not - 32 cartons collapsed into one cluster and nine different
|
||||
bottled drinks grouped by *container shape*.
|
||||
|
||||
Assignment is by *tag position*, not by geometric grouping: the tags on a rail
|
||||
mark where each product sits, so a facing belongs to the nearest tag along the
|
||||
row. Grouping facings by size and spacing was measured to merge different
|
||||
products (a 945 px "product" spanning half a shelf), which then swallowed the
|
||||
wrong tag.
|
||||
"""
|
||||
catalog_names = {
|
||||
sku_id: normalize_name(product.get("product_name"))
|
||||
for sku_id, product in catalog_by_sku.items()
|
||||
if product.get("product_name")
|
||||
}
|
||||
if not catalog_names:
|
||||
return {}
|
||||
|
||||
tags = [dict(tag) for tag in (all_tags or []) if tag.get("box")]
|
||||
for tag in tags:
|
||||
if tag.get("sku_from_name") is None:
|
||||
sku_id, score = best_catalog_match(tag.get("name"), catalog_names, min_similarity)
|
||||
tag["sku_from_name"] = sku_id
|
||||
tag["sku_score"] = score
|
||||
if not tags:
|
||||
return {}
|
||||
|
||||
found: Dict[int, Dict[str, Any]] = {}
|
||||
for row in cluster_into_rows(tracks):
|
||||
row_box = _group_box(row)
|
||||
row_height = max(1, row_box[3] - row_box[1])
|
||||
# The rail's tags sit just under the row they describe.
|
||||
row_tags = [
|
||||
tag
|
||||
for tag in tags
|
||||
if row_box[3] - 0.5 * (tag["box"][3] - tag["box"][1])
|
||||
<= tag["box"][1]
|
||||
<= row_box[3] + 1.5 * (tag["box"][3] - tag["box"][1])
|
||||
]
|
||||
if not row_tags:
|
||||
continue
|
||||
for track in row:
|
||||
box = track.get("box") or track.get("raw_box")
|
||||
center = (box[0] + box[2]) / 2.0
|
||||
nearest = min(
|
||||
row_tags,
|
||||
key=lambda tag: abs(center - (tag["box"][0] + tag["box"][2]) / 2.0),
|
||||
)
|
||||
sku_id = nearest.get("sku_from_name")
|
||||
if not sku_id:
|
||||
continue
|
||||
found[int(track["track_id"])] = {
|
||||
"sku_id": sku_id,
|
||||
"score": round(float(nearest.get("sku_score") or 0.0), 3),
|
||||
"source": "tag_name",
|
||||
}
|
||||
return found
|
||||
|
||||
|
||||
def attach_tags_to_boxes(
|
||||
tag_service: Any,
|
||||
image: Any,
|
||||
|
|
@ -594,11 +714,63 @@ def attach_tags_to_boxes(
|
|||
"track_id": int(box["id"]),
|
||||
"box": list(box["box"]),
|
||||
"identity": (identities or {}).get(int(box["id"])) or {},
|
||||
"tag_identity": {},
|
||||
}
|
||||
for box in boxes
|
||||
]
|
||||
tag_by_track, all_tags, counts = tag_service.attach(tracks)
|
||||
|
||||
# Tag-first identity, applied before reconciliation so the comparison below
|
||||
# sees the identity the tag itself provides.
|
||||
tag_identities = identities_from_tags(
|
||||
tracks, tag_by_track, catalog_by_sku, all_tags=all_tags
|
||||
)
|
||||
for track in tracks:
|
||||
derived = tag_identities.get(int(track["track_id"]))
|
||||
if not derived:
|
||||
continue
|
||||
# Always keep the tag's claim, even when packaging recognition already
|
||||
# named the product: two independent identity channels that can be
|
||||
# compared are worth more than one that silently wins. A disagreement is
|
||||
# itself a finding (a misplaced tag, or a product in the wrong place).
|
||||
track["tag_identity"] = dict(derived)
|
||||
current = track["identity"] or {}
|
||||
if current.get("state") == "recognized":
|
||||
if current.get("sku_id") != derived["sku_id"]:
|
||||
# Two channels disagree. Measured on the demo shelf: where both
|
||||
# named the same facing they agreed only 69% of the time, and the
|
||||
# packaging side of every disagreement carried a low score - so
|
||||
# the store's own label wins, and the disagreement is recorded
|
||||
# rather than hidden (a misplaced tag is a real finding).
|
||||
track["identity_conflict"] = {
|
||||
"packaging": current.get("sku_id"),
|
||||
"tag": derived["sku_id"],
|
||||
"packaging_score": current.get("score"),
|
||||
}
|
||||
track["identity"] = {
|
||||
"state": "recognized",
|
||||
"sku_id": derived["sku_id"],
|
||||
"identity_level": "tag_over_packaging",
|
||||
"confidence": derived["score"],
|
||||
"score": derived["score"],
|
||||
"source": "tag_name",
|
||||
"candidates": [],
|
||||
"attempts": 1,
|
||||
"method": "tag_name",
|
||||
}
|
||||
continue
|
||||
track["identity"] = {
|
||||
"state": "recognized",
|
||||
"sku_id": derived["sku_id"],
|
||||
"identity_level": "tag_derived",
|
||||
"confidence": derived["score"],
|
||||
"score": derived["score"],
|
||||
"source": "tag_name",
|
||||
"candidates": [],
|
||||
"attempts": 1,
|
||||
"method": "tag_name",
|
||||
}
|
||||
|
||||
reconciliations: Dict[int, Dict[str, Any]] = {}
|
||||
for track in tracks:
|
||||
track_id = track["track_id"]
|
||||
|
|
@ -615,6 +787,7 @@ def attach_tags_to_boxes(
|
|||
"tags": all_tags,
|
||||
"counts": counts,
|
||||
"reconciliations": reconciliations,
|
||||
"tag_identities": tag_identities,
|
||||
"bands": [{"row_index": band.row_index, "box": band.box} for band in bands],
|
||||
"bands_objects": list(bands),
|
||||
"compliance": {
|
||||
|
|
|
|||
105
recognization/code/test_tag_identity.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
"""
|
||||
Tests for identity from the shelf's own price tags.
|
||||
|
||||
The measured case these encode: the store's printed label names the product in
|
||||
clean text, one tag per product, and on the demo shelf the packaging matcher
|
||||
agreed with it only 69 % of the time where both spoke - so the tag wins a
|
||||
disagreement, and the disagreement is recorded rather than hidden.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
project_root = os.path.dirname(current_dir)
|
||||
if os.path.exists(os.path.join(project_root, "stock")):
|
||||
os.chdir(project_root)
|
||||
if current_dir not in sys.path:
|
||||
sys.path.insert(0, current_dir)
|
||||
|
||||
from reconcile import ( # noqa: E402
|
||||
TAG_STATE_ABSENT,
|
||||
TAG_STATE_READABLE,
|
||||
best_catalog_match,
|
||||
cluster_into_rows,
|
||||
identities_from_tags,
|
||||
normalize_name,
|
||||
)
|
||||
|
||||
CATALOG = {
|
||||
"sku_l05": {"sku_id": "sku_l05", "product_name": "キューピー ゼロ ノンコレステロール 310g", "price": 269},
|
||||
"sku_l07": {"sku_id": "sku_l07", "product_name": "丸和油脂 ラッキーマヨネーズ 200g", "price": 199},
|
||||
}
|
||||
|
||||
|
||||
def facing(track_id, box, sku=None):
|
||||
identity = {"state": "recognized", "sku_id": sku} if sku else {"state": "unconfirmed"}
|
||||
return {"track_id": track_id, "box": box, "identity": identity}
|
||||
|
||||
|
||||
def tag(box, name=None, state=TAG_STATE_READABLE):
|
||||
return {"state": state, "box": box, "name": name, "jan": None, "price": None}
|
||||
|
||||
|
||||
class NameMatchTests(unittest.TestCase):
|
||||
def test_an_ocr_name_still_matches_the_catalog(self):
|
||||
# Measured OCR output against the catalog entry it should find.
|
||||
sku, score = best_catalog_match(
|
||||
"キュービーゼロ ノンコレステロールく310g>",
|
||||
{k: normalize_name(v["product_name"]) for k, v in CATALOG.items()},
|
||||
)
|
||||
self.assertEqual(sku, "sku_l05")
|
||||
self.assertGreater(score, 0.5)
|
||||
|
||||
def test_unrelated_text_matches_nothing(self):
|
||||
sku, _ = best_catalog_match(
|
||||
"ラス BEをさいしく使べるまにてく。",
|
||||
{k: normalize_name(v["product_name"]) for k, v in CATALOG.items()},
|
||||
)
|
||||
self.assertIsNone(sku, "a garbled read must not be forced onto a product")
|
||||
|
||||
|
||||
class AssignmentTests(unittest.TestCase):
|
||||
def test_a_facing_is_named_by_the_tag_under_its_row(self):
|
||||
# A row of four facings, one tag under it, one product.
|
||||
tracks = [facing(index, [10 + index * 90, 100, 100 + index * 90, 300]) for index in range(4)]
|
||||
tags = [tag([40, 320, 260, 380], name="キューピー ゼロ ノンコレステロール 310g")]
|
||||
found = identities_from_tags(tracks, {}, CATALOG, all_tags=tags)
|
||||
self.assertEqual(len(found), 4, "one tag covers the whole run of facings")
|
||||
self.assertTrue(all(info["sku_id"] == "sku_l05" for info in found.values()))
|
||||
|
||||
def test_the_nearest_tag_wins_along_a_row(self):
|
||||
# Two products side by side, one tag each.
|
||||
tracks = [
|
||||
facing(0, [10, 100, 100, 300]),
|
||||
facing(1, [110, 100, 200, 300]),
|
||||
facing(2, [700, 100, 790, 300]),
|
||||
facing(3, [800, 100, 890, 300]),
|
||||
]
|
||||
tags = [
|
||||
tag([20, 320, 180, 380], name="キューピー ゼロ ノンコレステロール 310g"),
|
||||
tag([710, 320, 870, 380], name="丸和油脂 ラッキーマヨネーズ 200g"),
|
||||
]
|
||||
found = identities_from_tags(tracks, {}, CATALOG, all_tags=tags)
|
||||
self.assertEqual(found[0]["sku_id"], "sku_l05")
|
||||
self.assertEqual(found[1]["sku_id"], "sku_l05")
|
||||
self.assertEqual(found[2]["sku_id"], "sku_l07")
|
||||
self.assertEqual(found[3]["sku_id"], "sku_l07")
|
||||
|
||||
def test_an_unreadable_tag_names_nothing(self):
|
||||
tracks = [facing(0, [10, 100, 100, 300])]
|
||||
tags = [tag([20, 320, 180, 380], name=None, state="unreadable")]
|
||||
self.assertEqual(identities_from_tags(tracks, {}, CATALOG, all_tags=tags), {})
|
||||
|
||||
def test_rows_are_clustered_before_assignment(self):
|
||||
# The same column on two shelves must not become one product.
|
||||
tracks = [facing(0, [10, 100, 100, 300]), facing(1, [10, 600, 100, 800])]
|
||||
rows = cluster_into_rows(tracks)
|
||||
self.assertEqual(len(rows), 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
BIN
recognization/stock-lopia/sku_l01.jpeg
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
recognization/stock-lopia/sku_l02.jpeg
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
recognization/stock-lopia/sku_l03.jpeg
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
recognization/stock-lopia/sku_l04.jpeg
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
recognization/stock-lopia/sku_l05.jpeg
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
recognization/stock-lopia/sku_l07.jpeg
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
recognization/stock-lopia/sku_l09.jpeg
Normal file
|
After Width: | Height: | Size: 17 KiB |
13
recognization/stock-lopia/stock.csv
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
SKU_ID,product_name,receipt_date,expiration_date,number_in,number_out,number_on,image_link,price
|
||||
sku_l01,キューピー エッグケア 305g,2026-09-18,2026-12-20,36,12,24,'',349
|
||||
sku_l02,キューピー アマニ油マヨネーズ 200g,2026-09-18,2026-12-20,24,9,15,'',329
|
||||
sku_l03,キューピー からしマヨネーズ 200g,2026-09-18,2026-12-20,30,14,16,'',219
|
||||
sku_l04,やまや めんたいマヨネーズタイプ 200g,2026-09-18,2026-11-30,18,6,12,'',399
|
||||
sku_l05,キューピー ゼロ ノンコレステロール 310g,2026-09-20,2026-12-25,24,11,13,'',269
|
||||
sku_l06,キューピー フィッテ 210g,2026-09-20,2026-12-25,18,7,11,'',379
|
||||
sku_l07,丸和油脂 ラッキーマヨネーズ 200g,2026-09-20,2026-12-25,24,10,14,'',199
|
||||
sku_l08,スパイシーストーム ペリペリミックス 30g,2026-09-15,2027-03-15,12,4,8,'',699
|
||||
sku_l09,ピュアセレクト コクうま カロリーカット65% 360g,2026-09-22,2026-12-28,30,13,17,'',209
|
||||
sku_l10,ピュアセレクト マヨネーズ 450g,2026-09-22,2026-12-28,36,15,21,'',222
|
||||
sku_l11,キューピー ハーフマヨネーズ 450g,2026-09-19,2026-12-22,48,22,26,'',269
|
||||
sku_l12,焼肉のたれ 中辛,2026-09-10,2027-02-10,24,8,16,'',299
|
||||
|