feat: two demo shelves, enrolled, plus the price-card filter they forced out

Two real store photographs, chosen for density and for products that look different
from each other, each with its own enrolled store:

  demo-images/demo-image-1-mediya-dairy.jpg   CC0        MEDIYA dairy case
      21 products kept (13 price cards dropped), 16 recognised, all 14 products named
  demo-images/demo-image-2-milk-case.jpg      CC BY 2.0  supermarket milk case
      56 detections, 49 recognised, all 13 products named

Both were enrolled the way a store would be: the products were read off a contact
sheet of the detections, their crops became the store's gallery (stock-mediya,
stock-milk150), and a head was trained on them. `build_store_from_shelf.py` is that
flow.

The filter came out of the first image. Every Japanese supermarket photo has the same
artifact: the detector boxes the shelf-edge price cards as products, because a card is
roughly the shape of a pack - 13 of 34 detections on image 1 were cards. Their text
separates them cleanly: a card's characters are 30-44% digits and it prints the
tax-excluded price (本体), while a product's are 3-19% digits. Cards are now dropped
before classification, and the count is reported as `price_cards_dropped` rather than
hidden.

demo-images/README.md records how to run both, what the demo shows, and the honest
caveat: both shelves are enrolled, so the recognition rate is in-distribution - what
the app does on a shelf it has been onboarded to, not on one it has never seen.

175 tests pass.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
kiyo9w 2026-09-25 09:34:29 +09:00
parent 2bf916f2aa
commit 383ff9cf2d
72 changed files with 215 additions and 0 deletions

View file

@ -24,6 +24,7 @@ import hmac
import json
import logging
import os
import re
import sys
import threading
import time
@ -479,6 +480,48 @@ def create_app(
result = _attach_still_tags(result, image)
return result
def _is_price_card(crop: Any) -> bool:
"""Is this detection a shelf price card rather than a product?
Measured on a supermarket photo whose shelf edge carries big price cards:
the detector boxes them as products, because a card is roughly the shape of a
pack. Their text gives them away - a price card's characters are 30-44 %
digits and it prints the tax-excluded price (本体), while a product's are 3-19 %
digits. Reporting a price card as a product would put a box labelled with a
product name on a price tag, which is worse than not detecting it.
"""
ocr_engine = getattr(engine, "ocr_engine", None)
if ocr_engine is None or not getattr(ocr_engine, "available", lambda: False)():
return False
try:
import cv2
big = cv2.resize(crop, None, fx=2.0, fy=2.0, interpolation=cv2.INTER_CUBIC)
lines = ocr_engine.read(big).lines
except Exception:
return False
text = " ".join(line.text for line in lines)
compact = re.sub(r"\s", "", text)
if not compact:
return False
digits = sum(1 for char in compact if char.isdigit() or char in "¥¥./") / len(compact)
return ("本体" in text and digits >= 0.20) or digits >= 0.30
def _drop_price_cards(result: Dict[str, Any], image: Any) -> int:
"""Remove detections that are price cards, and say how many were removed."""
kept, dropped = [], 0
for detection in result["detections"]:
crop = engine.crop(image, detection["box"])
if crop is not None and crop.size and _is_price_card(crop):
dropped += 1
continue
kept.append(detection)
if dropped:
result["detections"] = kept
result["summary"] = engine.summarize(kept, result.get("empty_slots") or [])
result["price_cards_dropped"] = dropped
return dropped
def _attach_still_tags(result: Dict[str, Any], image: Any) -> Dict[str, Any]:
"""Run one synchronous tag pass over a still and merge its evidence."""
ocr_engine = getattr(engine, "ocr_engine", None)
@ -491,6 +534,9 @@ def create_app(
from reconcile import attach_tags_to_boxes
from tags import TagService, TagSettings
# Before anything else: a price card is not a product.
_drop_price_cards(result, image)
boxes = [
{"id": detection["id"], "box": detection["box"]} for detection in result["detections"]
]

View file

@ -0,0 +1,61 @@
"""
Enroll a shelf: the products read off its crops become the store's catalog.
This is the onboarding flow a real store would go through once - detect the facings,
name the products, and keep their crops as the gallery that recognition matches
against. The two demo shelves were built with it; the product lists live in this file
because reading a contact sheet is a human step.
python code/build_store_from_shelf.py
"""
import json, os, shutil, sys
sys.path.insert(0, "code")
import cv2
ROOT = os.path.expanduser("~/dorayaki-data/shelf-images")
manifest = json.load(open(os.path.join(ROOT, "manifest.json")))
entry = next(e for e in manifest["images"] if "Azabudai-Hills" in e["file"])
image = cv2.imread(os.path.join(ROOT, entry["file"]))
height, width = image.shape[:2]
scale = min(1.0, 1600.0 / max(height, width))
image = cv2.resize(image, (int(width*scale), int(height*scale)), interpolation=cv2.INTER_AREA)
boxes = json.load(open("/tmp/dorayaki-verify/mediya-boxes.json"))["boxes"]
# read off the numbered contact sheet; prices come from the shelf's own tags
PRODUCTS = [
("sku_m01", "小岩井 低脂肪牛乳", [9, 23, 31]),
("sku_m02", "荻山酪農 カフェ・オ・レ", [10]),
("sku_m03", "丹那 3.6牛乳", [11]),
("sku_m04", "木次 ミルクコーヒー", [12]),
("sku_m05", "木次 パスチャライズ牛乳", [13]),
("sku_m06", "キスキ ブラウン・スイス牛乳", [14]),
("sku_m07", "丹那 飲んでやさしいヨーグルト", [15]),
("sku_m08", "農場3.7牛乳 特選", [24]),
("sku_m09", "MINOR FIGURES OAT", [26]),
("sku_m10", "北海道 低脂肪乳", [27]),
("sku_m11", "タカナシ 低温殺菌牛乳", [28]),
("sku_m12", "RUDE HEALTH Oat Organic", [29]),
("sku_m13", "小岩井 無脂肪牛乳", [30]),
("sku_m14", "きすき ノンホモ牛乳", [8]),
]
PRICES = {"sku_m01": 186, "sku_m02": 397, "sku_m03": 214, "sku_m04": 195, "sku_m05": 240,
"sku_m06": 254, "sku_m07": 225, "sku_m08": 216, "sku_m09": 300, "sku_m10": 214,
"sku_m11": 396, "sku_m12": 400, "sku_m13": 178, "sku_m14": 198}
store = "stock-mediya"
shutil.rmtree(store, ignore_errors=True)
os.makedirs(store, exist_ok=True)
rows = ["SKU_ID,product_name,receipt_date,expiration_date,number_in,number_out,number_on,image_link,price"]
for sku, name, indices in PRODUCTS:
for position, index in enumerate(indices):
box = boxes[index]
x1, y1, x2, y2 = [int(v) for v in box]
crop = image[max(0,y1):y2, max(0,x1):x2]
if crop.size == 0:
continue
suffix = "" if position == 0 else f"_{position}"
cv2.imwrite(os.path.join(store, f"{sku}{suffix}.jpeg"), crop)
rows.append(f"{sku},{name},2026-09-25,2026-10-20,24,8,16,'',{PRICES[sku]}")
open(os.path.join(store, "stock.csv"), "w").write("\n".join(rows) + "\n")
print(f"{store}: {len(PRODUCTS)} products, "
f"{sum(len(i) for _, _, i in PRODUCTS)} views, csv written")

View file

@ -0,0 +1,16 @@
[
{
"file": "demo-image-1-mediya-dairy.jpg",
"title": "File:Azabudai-Hills---2025-04-05 015.jpg",
"license": "CC0",
"artist": "<a href=\"//commons.wikimedia.org/wiki/User:RuinDig\" title=\"User:RuinDig\">RuinDig/Yuki Uchida</a>",
"source": "https://commons.wikimedia.org/wiki/File%3AAzabudai-Hills---2025-04-05_015.jpg"
},
{
"file": "demo-image-2-milk-case.jpg",
"title": "File:Milk (2655574568).jpg",
"license": "CC BY 2.0",
"artist": "<a rel=\"nofollow\" class=\"external text\" href=\"https://www.flickr.com/people/36014533@N00\">yumtan</a>",
"source": "https://commons.wikimedia.org/wiki/File%3AMilk_%282655574568%29.jpg"
}
]

View file

@ -0,0 +1,63 @@
# The two demo shelves
Two real store photographs, chosen because they are dense and their products are
visually distinct, with a store catalog enrolled for each. Both come from Wikimedia
Commons under licences that allow reuse; `demo-images/ATTRIBUTION.json` records the
author, licence and source of each.
| | image 1 | image 2 |
| --- | --- | --- |
| file | `demo-images/demo-image-1-mediya-dairy.jpg` | `demo-images/demo-image-2-milk-case.jpg` |
| scene | a MEDIYA dairy case | a supermarket milk case |
| licence | **CC0** | **CC BY 2.0** |
| detections | 21 products (13 price cards filtered out) | 56 products |
| recognised | **16, covering all 14 products** | **49, covering all 13 products** |
| store | `stock-mediya/` | `stock-milk150/` |
## How to run them
```bash
cd recognization
YOLO_OFFLINE=1 .venv/bin/python code/app.py \
--port 8090 --identity gallery --live-conf 0.25 \
--stock-dir stock-mediya # or stock-milk150
```
Then POST the image, or open the web client and use **Photo** mode:
```bash
curl -s -X POST --data-binary @demo-images/demo-image-1-mediya-dairy.jpg \
-H 'content-type: image/jpeg' \
'http://127.0.0.1:8090/v1/recognize?classify=true&conf=0.25&tags=true' | python3 -m json.tool | head -30
```
## What the demo shows
* **Every product on the shelf is boxed and named** - 小岩井 低脂肪牛乳,
荻山酪農 カフェ・オ・レ, 丹那 3.6牛乳, 木次 ミルクコーヒー, 木次 パスチャライズ牛乳,
キスキ ブラウン・スイス牛乳, 丹那 飲んでやさしいヨーグルト, 農場3.7牛乳【特選】,
MINOR FIGURES OAT, 北海道 低脂肪乳, タカナシ 低温殺菌牛乳, RUDE HEALTH Oat Organic,
小岩井 無脂肪牛乳, きすき ノンホモ牛乳 on the first; 明治 おいしい牛乳, ゆうパック
優良牛乳, 酪農家限定 低温殺菌牛乳, 北海道牛乳, MEGMILK, 毎日牛乳, タカナシ 低温殺菌牛乳,
タカナシ 殺菌低脂肪牛乳, 毎日骨太, カルシウム入り低脂肪乳, 無脂肪牛乳, 低脂肪牛乳, ミルク
on the second.
* **Price cards are not boxed as products.** The detector finds them (a card is the
shape of a pack); their text gives them away - a card's characters are 30-44 %
digits and it prints the tax-excluded price (本体), a product's are 3-19 %. Measured
on image 1: 13 cards dropped, 21 products kept.
* **What it cannot name, it does not invent.** Boxes on the dark shelf strips and on
cards come back as "check" (amber), not as a product name.
## The honest caveat, for the demo
Both shelves were **enrolled** - their products were read off the shelf and their
crops became the store's catalog and its fine-tuned head, which is the product's own
onboarding flow (a store is enrolled once, then scanned). So the recognition rate
above is **in-distribution**: it is what the app does on a shelf it has been enrolled
on, not on a shelf it has never seen. On shelves it has not been enrolled on, the
measured behaviour is: detection transfers (46 of 54 unseen photos), identity does not
(off-the-shelf embeddings reach ~25 % top-1 on fine-grained products; see
`docs/EXTERNAL-EVAL.md`).
The first image is CC0, so it can be used in any recording without attribution; the
second is CC BY 2.0 and needs the credit in `ATTRIBUTION.json` if it appears on screen.

Binary file not shown.

After

Width:  |  Height:  |  Size: 783 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View file

@ -0,0 +1,15 @@
SKU_ID,product_name,receipt_date,expiration_date,number_in,number_out,number_on,image_link,price
sku_m01,小岩井 低脂肪牛乳,2026-09-25,2026-10-20,24,8,16,'',186
sku_m02,荻山酪農 カフェ・オ・レ,2026-09-25,2026-10-20,24,8,16,'',397
sku_m03,丹那 3.6牛乳,2026-09-25,2026-10-20,24,8,16,'',214
sku_m04,木次 ミルクコーヒー,2026-09-25,2026-10-20,24,8,16,'',195
sku_m05,木次 パスチャライズ牛乳,2026-09-25,2026-10-20,24,8,16,'',240
sku_m06,キスキ ブラウン・スイス牛乳,2026-09-25,2026-10-20,24,8,16,'',254
sku_m07,丹那 飲んでやさしいヨーグルト,2026-09-25,2026-10-20,24,8,16,'',225
sku_m08,農場3.7牛乳 特選,2026-09-25,2026-10-20,24,8,16,'',216
sku_m09,MINOR FIGURES OAT,2026-09-25,2026-10-20,24,8,16,'',300
sku_m10,北海道 低脂肪乳,2026-09-25,2026-10-20,24,8,16,'',214
sku_m11,タカナシ 低温殺菌牛乳,2026-09-25,2026-10-20,24,8,16,'',396
sku_m12,RUDE HEALTH Oat Organic,2026-09-25,2026-10-20,24,8,16,'',400
sku_m13,小岩井 無脂肪牛乳,2026-09-25,2026-10-20,24,8,16,'',178
sku_m14,きすき ノンホモ牛乳,2026-09-25,2026-10-20,24,8,16,'',198
1 SKU_ID product_name receipt_date expiration_date number_in number_out number_on image_link price
2 sku_m01 小岩井 低脂肪牛乳 2026-09-25 2026-10-20 24 8 16 '' 186
3 sku_m02 荻山酪農 カフェ・オ・レ 2026-09-25 2026-10-20 24 8 16 '' 397
4 sku_m03 丹那 3.6牛乳 2026-09-25 2026-10-20 24 8 16 '' 214
5 sku_m04 木次 ミルクコーヒー 2026-09-25 2026-10-20 24 8 16 '' 195
6 sku_m05 木次 パスチャライズ牛乳 2026-09-25 2026-10-20 24 8 16 '' 240
7 sku_m06 キスキ ブラウン・スイス牛乳 2026-09-25 2026-10-20 24 8 16 '' 254
8 sku_m07 丹那 飲んでやさしいヨーグルト 2026-09-25 2026-10-20 24 8 16 '' 225
9 sku_m08 農場3.7牛乳 特選 2026-09-25 2026-10-20 24 8 16 '' 216
10 sku_m09 MINOR FIGURES OAT 2026-09-25 2026-10-20 24 8 16 '' 300
11 sku_m10 北海道 低脂肪乳 2026-09-25 2026-10-20 24 8 16 '' 214
12 sku_m11 タカナシ 低温殺菌牛乳 2026-09-25 2026-10-20 24 8 16 '' 396
13 sku_m12 RUDE HEALTH Oat Organic 2026-09-25 2026-10-20 24 8 16 '' 400
14 sku_m13 小岩井 無脂肪牛乳 2026-09-25 2026-10-20 24 8 16 '' 178
15 sku_m14 きすき ノンホモ牛乳 2026-09-25 2026-10-20 24 8 16 '' 198

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

View file

@ -0,0 +1,14 @@
SKU_ID,product_name,receipt_date,expiration_date,number_in,number_out,number_on,image_link,price
sku_k01,明治 おいしい牛乳,2026-09-25,2026-10-15,48,20,28,'',198
sku_k02,ゆうパック 優良牛乳,2026-09-25,2026-10-15,48,20,28,'',130
sku_k03,酪農家限定 低温殺菌牛乳,2026-09-25,2026-10-15,48,20,28,'',248
sku_k04,北海道牛乳,2026-09-25,2026-10-15,48,20,28,'',178
sku_k05,MEGMILK,2026-09-25,2026-10-15,48,20,28,'',158
sku_k06,毎日牛乳,2026-09-25,2026-10-15,48,20,28,'',168
sku_k07,タカナシ 低温殺菌牛乳,2026-09-25,2026-10-15,48,20,28,'',258
sku_k08,タカナシ 殺菌低脂肪牛乳,2026-09-25,2026-10-15,48,20,28,'',198
sku_k09,毎日骨太,2026-09-25,2026-10-15,48,20,28,'',148
sku_k10,カルシウム入り低脂肪乳,2026-09-25,2026-10-15,48,20,28,'',145
sku_k11,無脂肪牛乳,2026-09-25,2026-10-15,48,20,28,'',138
sku_k12,低脂肪牛乳,2026-09-25,2026-10-15,48,20,28,'',120
sku_k13,ミルク,2026-09-25,2026-10-15,48,20,28,'',260
1 SKU_ID product_name receipt_date expiration_date number_in number_out number_on image_link price
2 sku_k01 明治 おいしい牛乳 2026-09-25 2026-10-15 48 20 28 '' 198
3 sku_k02 ゆうパック 優良牛乳 2026-09-25 2026-10-15 48 20 28 '' 130
4 sku_k03 酪農家限定 低温殺菌牛乳 2026-09-25 2026-10-15 48 20 28 '' 248
5 sku_k04 北海道牛乳 2026-09-25 2026-10-15 48 20 28 '' 178
6 sku_k05 MEGMILK 2026-09-25 2026-10-15 48 20 28 '' 158
7 sku_k06 毎日牛乳 2026-09-25 2026-10-15 48 20 28 '' 168
8 sku_k07 タカナシ 低温殺菌牛乳 2026-09-25 2026-10-15 48 20 28 '' 258
9 sku_k08 タカナシ 殺菌低脂肪牛乳 2026-09-25 2026-10-15 48 20 28 '' 198
10 sku_k09 毎日骨太 2026-09-25 2026-10-15 48 20 28 '' 148
11 sku_k10 カルシウム入り低脂肪乳 2026-09-25 2026-10-15 48 20 28 '' 145
12 sku_k11 無脂肪牛乳 2026-09-25 2026-10-15 48 20 28 '' 138
13 sku_k12 低脂肪牛乳 2026-09-25 2026-10-15 48 20 28 '' 120
14 sku_k13 ミルク 2026-09-25 2026-10-15 48 20 28 '' 260