product-recognition-demo/recognization/code/collect_shelf_images.py
kiyo9w 0e23425ef0 feat: a live path that uses the enrolled store, and a demo server a phone can reach
The live session scored crops with the matcher regardless of the configured identity
backend, so a live scan used the old mechanism while the still path used the enrolled
gallery - measured, the matcher accepts look-alikes and cannot say "unknown", which is
exactly what a real shelf needs it to do. score_crop now routes to the gallery, and
the decision uses a similarity scale (accept at 0.60 with a 0.04 margin) rather than
inlier-count thresholds that mean nothing on a cosine.

The synthetic walk is gone: demo.py, demo_replay.py, the /v1/demo endpoints and the
walk benchmark are deleted, and the app's frame source is the camera on a device (the
single-image replay stays for the simulator, which has no camera).

stock-demo merges both demo shelves (27 products, 62 views, trained head) so one
server recognises either image, and run-demo-server.sh starts it bound to 0.0.0.0 and
prints the exact URL to put in Config.xcconfig.

Verified over the LAN with 1280x960 frames: 40 frames in 5.3s, 9 products recognised
on demo image 1.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-25 10:51:57 +09:00

190 lines
7.1 KiB
Python

"""
Collect openly licensed store-shelf photographs for evaluation.
Everything measured so far comes from two photographs. This pulls real store photos
from Wikimedia Commons - scriptable, licensed, and no authentication - so the
pipeline can be measured on images it has never seen.
Only permissively licensed files are kept (CC0, CC BY, CC BY-SA, public domain), and
each one is written to a manifest with its author, licence and source URL, so an
attribution page can be produced from it.
python code/collect_shelf_images.py [output_dir]
"""
from __future__ import annotations
import json
import os
import ssl
import sys
import time
import urllib.parse
import urllib.request
import certifi
CONTEXT = ssl.create_default_context(cafile=certifi.where())
API = "https://commons.wikimedia.org/w/api.php"
USER_AGENT = "dorayaki-eval/0.1 (shelf recognition research; contact: repo owner)"
# Commons asks for a polite pace and offers thumbnails instead of originals. Both
# matter: a burst of original downloads got rate limited (HTTP 429) on the first run.
THUMBNAIL_WIDTH = 1600
REQUEST_DELAY = 1.2
MAX_RETRIES = 4
# Shelf photos of the categories the catalog covers: drinks and dairy, rice
# crackers and snacks, sauces and condiments.
QUERIES = [
# the catalog's own products first: drinks, dairy, rice crackers
"野菜生活100",
"伊藤園 1日分の野菜",
"Japanese vegetable juice shelf",
"Japanese milk cartons supermarket",
"meiji oishii milk",
"せんべい 棚",
"Japanese rice cracker shop display",
"Japanese confectionery shelf",
"Japanese supermarket beverage aisle",
"Japanese convenience store refrigerator drinks",
"Japanese supermarket dairy case",
"スーパー 飲料 陳列",
"お菓子 売り場",
"Japanese snack aisle supermarket",
"Japanese supermarket shelves drinks bottles",
"Japanese supermarket interior shelves",
]
PERMISSIVE = ("cc0", "cc-by", "cc by", "public domain", "pd-", "attribution")
def fetch(url: str, timeout: int = 60) -> bytes:
"""GET with backoff: Commons answers 429 when a client is too eager."""
for attempt in range(MAX_RETRIES):
try:
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(request, timeout=timeout, context=CONTEXT) as response:
return response.read()
except urllib.error.HTTPError as error:
if error.code != 429 or attempt == MAX_RETRIES - 1:
raise
time.sleep(REQUEST_DELAY * (2 ** attempt))
raise RuntimeError("unreachable")
def api(params: dict) -> dict:
url = API + "?" + urllib.parse.urlencode(params)
time.sleep(REQUEST_DELAY)
return json.loads(fetch(url, timeout=30).decode("utf-8"))
def search_files() -> dict:
"""Candidate files, with how many queries found each one."""
found: dict = {}
for query in QUERIES:
try:
result = api({"action": "query", "list": "search", "srsearch": query,
"srnamespace": "6", "srlimit": "25", "format": "json"})
except Exception as error:
print(f" search failed for {query!r}: {error}")
continue
for hit in result.get("query", {}).get("search", []):
title = hit["title"]
if title.lower().endswith((".svg", ".pdf", ".ogv", ".webm")):
continue
found[title] = found.get(title, 0) + 1
time.sleep(0.2)
return found
def image_info(titles: list) -> dict:
"""URL, size and licence for each file, in batches of 50."""
info: dict = {}
for start in range(0, len(titles), 50):
batch = titles[start:start + 50]
try:
result = api({"action": "query", "titles": "|".join(batch), "prop": "imageinfo",
"iiprop": "url|size|extmetadata", "iiurlwidth": str(THUMBNAIL_WIDTH),
"format": "json"})
except Exception as error:
print(f" info failed for batch {start}: {error}")
continue
for page in (result.get("query", {}).get("pages") or {}).values():
entries = page.get("imageinfo") or []
if not entries:
continue
entry = entries[0]
meta = entry.get("extmetadata") or {}
info[page["title"]] = {
"url": entry.get("thumburl") or entry.get("url"),
"width": entry.get("width"),
"height": entry.get("height"),
"license": (meta.get("LicenseShortName") or {}).get("value", ""),
"artist": (meta.get("Artist") or {}).get("value", ""),
"description": (meta.get("ImageDescription") or {}).get("value", "")[:200],
"page": f"https://commons.wikimedia.org/wiki/{urllib.parse.quote(page['title'].replace(' ', '_'))}",
}
time.sleep(0.2)
return info
def main() -> None:
root = sys.argv[1] if len(sys.argv) > 1 else os.path.expanduser("~/dorayaki-data/shelf-images")
os.makedirs(root, exist_ok=True)
print("searching Commons...")
found = search_files()
print(f" {len(found)} distinct candidate files")
info = image_info(sorted(found))
print(f" metadata for {len(info)} files")
kept, skipped = [], {"license": 0, "size": 0, "missing": 0}
for title, entry in sorted(info.items()):
if not entry.get("url"):
skipped["missing"] += 1
continue
licence = (entry.get("license") or "").lower()
if not any(token in licence for token in PERMISSIVE):
skipped["license"] += 1
continue
if (entry.get("width") or 0) < 1000 or (entry.get("height") or 0) < 700:
skipped["size"] += 1
continue
kept.append((title, entry))
print(f" keeping {len(kept)} (skipped: {skipped})")
manifest = []
for index, (title, entry) in enumerate(kept):
stem = os.path.basename(urllib.parse.urlparse(entry["url"]).path).replace(" ", "_")
stem = urllib.parse.unquote(stem)[:60]
name = f"{index:03d}_{stem}"
path = os.path.join(root, name)
if not os.path.exists(path):
try:
payload = fetch(entry["url"])
with open(path, "wb") as handle:
handle.write(payload)
except Exception as error:
print(f" download failed for {title}: {error}")
continue
manifest.append({
"file": name,
"title": title,
"width": entry["width"],
"height": entry["height"],
"license": entry["license"],
"artist": entry["artist"],
"source": entry["page"],
"queries": found.get(title, 0),
})
print(f" [{index:>3}] {entry['width']}x{entry['height']} {entry['license']:<14} {title}")
manifest_path = os.path.join(root, "manifest.json")
with open(manifest_path, "w", encoding="utf-8") as handle:
json.dump({"source": "Wikimedia Commons", "images": manifest}, handle, ensure_ascii=False, indent=1)
print(f"\n{len(manifest)} images in {root}\nmanifest: {manifest_path}")
if __name__ == "__main__":
main()