Compare commits

..

No commits in common. "a716f5aab12aaeac6bce4fa7b05f818c77b1c81b" and "443a1957a9e42f4dd227873dd9991d25ceac497e" have entirely different histories.

184 changed files with 139 additions and 80074 deletions

View file

@ -25,14 +25,3 @@ stock-*/identity_head.pt
.gallery_cache_*.npz
.demo-token
config/local.env
# Fetched images. Every set keeps its manifest.json (source URL, licence,
# artist) and its results in git; the pixels are re-fetchable from those URLs
# and are 59 MB for the Wikimedia shelves alone.
data/external-shelves/*/*.jpg
data/external-shelves/*/*.jpeg
data/external-shelves/*/*.JPG
data/external-shelves/*/*.png
data/packshots/research/*/images/
data/packshots/research/*/pages/
data/packshots/research/*/*.jpg

View file

@ -1010,9 +1010,8 @@ def main() -> 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("--identity", type=str, default=None,
choices=["matcher", "local", "gallery", "van"],
help="which mechanism names products (default local; "
"van is the always-argmax baseline)")
choices=["matcher", "local", "gallery"],
help="which mechanism names products (default local)")
parser.add_argument("--live-inflight", type=int, default=None,
help="classifications in flight per session")
parser.add_argument("--live-attempts", type=int, default=None,

View file

@ -1,42 +0,0 @@
"""OCR cost per crop, on production-shaped crops.
The decision record claims ~18-26 ms per crop on Mac and re-measured 18.0 ms on
40 production crops. This is that measurement, so the number is reproducible
rather than remembered.
.venv/bin/python code/bench_ocr.py
"""
import json, statistics, sys, time
sys.path.insert(0, 'code')
import cv2
from eval_identity import LocalLaneAdapter, crop_of
lane = LocalLaneAdapter('stock-lopia-clean')
labels = json.load(open('eval/labels/image6.json'))
image = cv2.imread('add/image6.jpg')
crops = []
for facing in labels['facings']:
if facing.get('label') in (None, 'exclude'):
continue
crop = crop_of(image, facing['box'])
if crop is not None and crop.size:
crops.append(crop)
if len(crops) >= 40:
break
# Warm the recognizer: the first call loads the Vision model.
lane.text_lane.read(crops[0])
times = []
for crop in crops:
started = time.perf_counter()
lane.text_lane.read(crop)
times.append((time.perf_counter() - started) * 1000)
times.sort()
print(f'{len(times)} crops')
print(f' p50 {statistics.median(times):.1f} ms')
print(f' mean {statistics.mean(times):.1f} ms')
print(f' p95 {times[int(len(times) * 0.95)]:.1f} ms')
print(f' min {times[0]:.1f} max {times[-1]:.1f}')

View file

@ -129,9 +129,7 @@ def image_info(titles: list) -> dict:
def main() -> None:
default = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"data", "external-shelves", "wikimedia-commons")
root = sys.argv[1] if len(sys.argv) > 1 else default
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...")
@ -141,7 +139,7 @@ def main() -> None:
info = image_info(sorted(found))
print(f" metadata for {len(info)} files")
kept, skipped = [], {"license": 0, "size": 0, "missing": 0, "not_a_photo": 0}
kept, skipped = [], {"license": 0, "size": 0, "missing": 0}
for title, entry in sorted(info.items()):
if not entry.get("url"):
skipped["missing"] += 1
@ -150,12 +148,6 @@ def main() -> None:
if not any(token in licence for token in PERMISSIVE):
skipped["license"] += 1
continue
# A book scan is not a shelf. The search terms pull in .djvu volumes
# whose page 1 is a title page, and Commons serves those as JPEG
# thumbnails, so the extension check has to be on the source title.
if any(title.lower().endswith(ext) for ext in (".djvu", ".pdf", ".svg", ".tif", ".tiff")):
skipped["not_a_photo"] += 1
continue
if (entry.get("width") or 0) < 1000 or (entry.get("height") or 0) < 700:
skipped["size"] += 1
continue
@ -164,13 +156,9 @@ def main() -> None:
print(f" keeping {len(kept)} (skipped: {skipped})")
manifest = []
for index, (title, entry) in enumerate(kept):
# Truncate the stem, never the extension: a 60-character cut on the
# whole basename drops it, and the file then has no type for cv2 or
# for anyone reading the directory.
stem, extension = os.path.splitext(
os.path.basename(urllib.parse.urlparse(entry["url"]).path).replace(" ", "_")
)
name = f"{index:03d}_{urllib.parse.unquote(stem)[:60]}{extension.lower()}"
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:

View file

@ -89,11 +89,8 @@ class EngineSettings:
# calibrated open-set gate: measured on the corrected image6 labels it shows
# 36 of 61 in-catalog facings at 95% precision with the text lane, against
# 35 at 35% for Van's always-argmax matcher. `matcher` keeps Van's composite
# as a selectable baseline lane (E-1). `van` is that same scorer with the
# gate removed - it answers its top SKU every time, which is Van's actual
# contract and the only way to reproduce the stream comparison in
# DECISION.md. `gallery` is fenced off: raw DINOv2 is 12-21% top-1 on 7
# SKUs and its recall@10 falls to 7/42 at 493 SKUs.
# as a selectable baseline lane (E-1). `gallery` is fenced off: raw DINOv2
# is 12-21% top-1 on 7 SKUs and its recall@10 falls to 7/42 at 493 SKUs.
identity_backend: str = "local"
# Cosine similarity below which the gallery calls a facing unknown. Measured:
# same-product pairs sit above 0.6, different products below 0.5.
@ -472,8 +469,6 @@ class RecognitionEngine:
return "cosine"
if backend == "local":
return "local"
if backend == "van":
return "van"
return "inlier"
def identity_text_lane(self) -> Any:

View file

@ -1,116 +0,0 @@
"""Open-world false-accept probe: unrelated shelf photos through the shipped lane.
data/external-shelves/ holds Wikimedia Commons supermarket photos of shelves
that share nothing with the LOPIA catalog. Every accept here is a false accept
by construction, which is what makes the set useful: the image6 shelf can only
measure false accepts on the products it happens to contain.
The stored eval.json predates the open-set gate, so it is re-run rather than
trusted.
.venv/bin/python code/eval_distractors.py --stock stock-lopia-clean \
data/external-shelves/wikimedia-commons
"""
from __future__ import annotations
import argparse
import glob
import json
import os
import sys
import time
from typing import Any, Dict, List
current_dir = os.path.dirname(os.path.abspath(__file__))
if current_dir not in sys.path:
sys.path.insert(0, current_dir)
import cv2 # noqa: E402
from eval_identity import LOCAL_ACCEPT, LocalLaneAdapter # noqa: E402
def main(argv: List[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("directories", nargs="+")
parser.add_argument("--stock", default="stock-lopia-clean")
parser.add_argument("--write", action="store_true")
parser.add_argument("--limit", type=int, default=0, help="images per set (0 = all)")
args = parser.parse_args(argv)
from engine import build_default_engine, EngineSettings
engine = build_default_engine(EngineSettings(stock_dir=args.stock))
lane = LocalLaneAdapter(args.stock)
for directory in args.directories:
manifest_path = os.path.join(directory, "manifest.json")
try:
with open(manifest_path) as handle:
manifest = json.load(handle)
except (OSError, ValueError):
manifest = {"images": []}
meta = {row["file"]: row for row in manifest.get("images", [])}
rows: List[Dict[str, Any]] = []
paths = sorted(
path for path in glob.glob(os.path.join(directory, "*"))
if os.path.splitext(path)[1].lower() in (".jpg", ".jpeg", ".png")
)
if args.limit:
paths = paths[: args.limit]
for path in paths:
name = os.path.basename(path)
image = cv2.imread(path)
if image is None:
continue
started = time.perf_counter()
detections = engine.detect(image)
accepted = 0
for detection in detections:
box = detection.get("box") or detection.get("bbox")
if not box:
continue
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
decision = lane.decide(crop, lane.rank(crop), accept=LOCAL_ACCEPT,
margin=0.0, scale="full")
if decision.get("best_sku"):
accepted += 1
rows.append({
"file": name,
"title": meta.get(name, {}).get("title", ""),
"detections": len(detections),
"false_accepts": accepted,
"ms": round((time.perf_counter() - started) * 1000),
})
detections = sum(r["detections"] for r in rows)
accepts = sum(r["false_accepts"] for r in rows)
report = {
"dataset": os.path.basename(directory.rstrip("/")),
"catalog": args.stock,
"note": "Unrelated shelves: every accept is a false accept by construction.",
"images": len(rows),
"detections": detections,
"false_accepts": accepts,
"false_accept_rate": round(accepts / detections, 4) if detections else None,
"rows": rows,
}
print(f"{report['dataset']}: {len(rows)} images, {detections} detections, "
f"{accepts} false accepts ({report['false_accept_rate'] or 0:.2%})")
for row in sorted(rows, key=lambda r: -r["false_accepts"])[:5]:
if row["false_accepts"]:
print(f" {row['false_accepts']:>3} {row['file'][:55]}")
if args.write:
out = os.path.join(directory, "eval_result.json")
with open(out, "w") as handle:
json.dump(report, handle, ensure_ascii=False, indent=1)
print(f" wrote {out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,127 +0,0 @@
"""Score external-proxy image sets through the shipped identity lane.
These sets are not Tier 2 (see data/external-eval/README.md). They exist to test
the pipeline on data it had no part in building, so the one thing this script
must never do is score an image that has since been enrolled - that is
training-set accuracy wearing a held-out label.
`leaked()` is the same check the shelf evaluator runs, and it is applied here
for that reason: enrolling a packshot from an external set is a normal thing to
do (it is how F-12 was closed), and it silently converts one of these rows into
a leak unless something refuses it.
.venv/bin/python code/eval_external.py --stock stock-lopia-clean \
data/external-eval/kewpie data/external-eval/other-mayo
"""
from __future__ import annotations
import argparse
import glob
import json
import os
import sys
from typing import Any, Dict, List
current_dir = os.path.dirname(os.path.abspath(__file__))
if current_dir not in sys.path:
sys.path.insert(0, current_dir)
import cv2 # noqa: E402
from eval_identity import LOCAL_ACCEPT, LocalLaneAdapter, leaked # noqa: E402
def score_set(lane: Any, directory: str, stock_dir: str) -> Dict[str, Any]:
rows: List[Dict[str, Any]] = []
for path in sorted(glob.glob(os.path.join(directory, "*.jpg"))):
name = os.path.basename(path)
truth = "sku_" + name.split("_")[1]
image = cv2.imread(path)
if image is None:
rows.append({"file": name, "truth": truth, "verdict": "unreadable"})
continue
# The whole image is the crop: an external packshot is not a shelf facing.
box = (0, 0, image.shape[1], image.shape[0])
leak = leaked(image, box, stock_dir)
if leak:
rows.append({
"file": name, "truth": truth, "verdict": "leaked",
"leaked_into": leak,
"note": "enrolled in the catalog; scoring it would be training accuracy",
})
continue
decision = lane.decide(
image, lane.rank(image), accept=LOCAL_ACCEPT, margin=0.0, scale="full"
)
best = decision.get("best_sku")
verdict = "correct" if best == truth else ("abstain" if not best else "wrong")
rows.append({
"file": name, "truth": truth, "best": best,
"confidence": decision.get("confidence"),
"status": decision.get("status"),
"source": decision.get("source"),
"verdict": verdict,
})
scored = [r for r in rows if r["verdict"] in ("correct", "abstain", "wrong")]
counts = {v: sum(1 for r in scored if r["verdict"] == v)
for v in ("correct", "abstain", "wrong")}
# Per SKU, because a set total hides which products the lane can and cannot
# name. Every SKU here has images from two independent source domains, so a
# SKU that scores the same on both is not one lucky photograph.
by_sku: Dict[str, Dict[str, int]] = {}
for row in scored:
bucket = by_sku.setdefault(row["truth"], {"correct": 0, "abstain": 0, "wrong": 0})
bucket[row["verdict"]] += 1
return {
"dataset": os.path.basename(directory.rstrip("/")),
"catalog": stock_dir,
"note": "External proxy images, not Tier 2. Scored through the shipped "
"LocalLaneAdapter + DecisionPolicy.",
"rows": rows,
"scored": len(scored),
"leaked": sum(1 for r in rows if r["verdict"] == "leaked"),
"by_sku": by_sku,
**counts,
}
def main(argv: List[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("directories", nargs="+", help="external set directories")
parser.add_argument("--stock", default="stock-lopia-clean")
parser.add_argument("--write", action="store_true",
help="write eval_result.json into each set directory")
args = parser.parse_args(argv)
lane = LocalLaneAdapter(args.stock)
totals = {"correct": 0, "abstain": 0, "wrong": 0, "leaked": 0, "scored": 0}
for directory in args.directories:
report = score_set(lane, directory, args.stock)
for key in totals:
totals[key] += report[key]
print(f"{report['dataset']}: correct {report['correct']} | "
f"abstain {report['abstain']} | wrong {report['wrong']} | "
f"leaked {report['leaked']} of {report['scored']} scored")
for sku, bucket in sorted(report["by_sku"].items()):
print(f" {sku:<10} correct {bucket['correct']} "
f"abstain {bucket['abstain']} wrong {bucket['wrong']}")
for row in report["rows"]:
if row["verdict"] in ("wrong", "leaked"):
print(f" {row['verdict'].upper():<7} {row['file']} "
f"truth={row['truth']} best={row.get('best')} "
f"{row.get('leaked_into', '')}")
if args.write:
out = os.path.join(directory, "eval_result.json")
with open(out, "w") as handle:
json.dump(report, handle, ensure_ascii=False, indent=1)
print(f" wrote {out}")
print(f"\nTOTAL: {totals['scored']} scored, correct {totals['correct']}, "
f"abstain {totals['abstain']}, FALSE ACCEPTS {totals['wrong']}, "
f"leaked {totals['leaked']}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,14 +1,10 @@
"""
Run the pipeline over store photos it has never seen.
python code/eval_external_images.py [data/external-shelves/wikimedia-commons]
python code/eval_external_images.py [~/dorayaki-data/shelf-images]
Reports detection and identity per image, and writes a JSON summary. See
docs/EXTERNAL-EVAL.md for what the numbers mean.
The images live in the repository (`data/external-shelves/`), not in a home
directory: they are evaluation evidence, and evidence that only exists on one
machine cannot be re-checked.
"""
import json, os, sys, time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@ -17,9 +13,8 @@ from engine import build_default_engine, EngineSettings
from tags import TagService, TagSettings
from reconcile import attach_tags_to_boxes
DEFAULT_ROOT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"data", "external-shelves", "wikimedia-commons")
ROOT = os.path.expanduser(sys.argv[1] if len(sys.argv) > 1 else DEFAULT_ROOT)
ROOT = os.path.expanduser(sys.argv[1] if len(sys.argv) > 1 else "~/dorayaki-data/shelf-images")
manifest = json.load(open(os.path.join(ROOT, "manifest.json")))
engine = build_default_engine(EngineSettings(stock_dir="stock-lopia"))
catalog_by_sku = {p["sku_id"]: p for p in engine.catalog()}

View file

@ -78,12 +78,6 @@ LIVE_LONG_SIDE = 1280
# the enrollment protocol's session manifest (DECISION §8); this is the cheap
# backstop that runs without one.
LEAK_SIGNATURE_MAD = 20.0
# A signature hit is confirmed by correlation before it counts as a leak. A
# genuine re-cut, re-encode or rescale of an enrolled view correlates at 0.99+;
# two different photographs of the same product sit near 0.3. Measured on the
# l09 pair that the signature alone misjudged (0.28) and on re-encoded and
# rescaled copies of the enrolled view (0.999, 0.997).
LEAK_CORRELATION = 0.90
def load_labels(path: str) -> Dict[str, Any]:
@ -225,24 +219,6 @@ def _catalog_templates(stock_dir: str) -> List[Tuple[str, str, Tuple[int, int],
return cached
def _correlation(left: Any, right: Any) -> float:
"""Mean-subtracted correlation of two images, resizing `right` to `left`.
Scale- and re-encode-tolerant, unlike a pixel comparison: the enrolled view
and a re-saved or rescaled copy of it correlate at 0.99+, while two
different photographs of the same product sit near 0.3.
"""
if right.shape[:2] != left.shape[:2]:
right = cv2.resize(right, (left.shape[1], left.shape[0]),
interpolation=cv2.INTER_AREA)
a = cv2.cvtColor(left, cv2.COLOR_BGR2GRAY).astype(np.float32).ravel()
b = cv2.cvtColor(right, cv2.COLOR_BGR2GRAY).astype(np.float32).ravel()
a -= a.mean()
b -= b.mean()
denom = float(np.linalg.norm(a) * np.linalg.norm(b))
return float((a * b).sum() / denom) if denom else 0.0
def leaked(image: Any, box: Sequence[int], stock_dir: str) -> Optional[str]:
"""Name of the catalog template this crop is, or None when it is held out.
@ -262,21 +238,9 @@ def leaked(image: Any, box: Sequence[int], stock_dir: str) -> Optional[str]:
return None
crop_signatures = _signatures(crop)
for stem, name, shape, signatures in _catalog_templates(stock_dir):
close = min(float(np.abs(signature - crop_signature).mean())
for signature in signatures for crop_signature in crop_signatures)
if close < LEAK_SIGNATURE_MAD:
# The signature is a 64x64 grayscale thumbnail, so it is coarse
# enough to fire on two different photos of the same product: the
# l09 rear label from a retailer page sits 19.5 from the enrolled
# front packshot, under the 20.0 line, while being a different
# photograph (resized MAD 26.3). Confirm before calling it a leak.
# A genuine re-cut or re-encode correlates at 0.99+; that pair
# correlates at 0.28, so the two signals together are decisive
# where either alone is not.
template = cv2.imread(os.path.join(stock_dir, name))
if template is None or _correlation(crop, template) >= LEAK_CORRELATION:
return stem
continue
if min(float(np.abs(signature - crop_signature).mean())
for signature in signatures for crop_signature in crop_signatures) < LEAK_SIGNATURE_MAD:
return stem
if shape != crop.shape[:2]:
continue
template = cv2.imread(os.path.join(stock_dir, name))
@ -413,14 +377,10 @@ class LocalLaneAdapter(Lane):
name = "local"
def __init__(self, stock_dir: str, ocr: Any = None, use_text: bool = True) -> None:
def __init__(self, stock_dir: str, ocr: Any = None) -> None:
from identity_lanes import LocalLane, TextLane
self.lane = LocalLane(stock_dir)
# E-3 asks what the text lane is worth, which is only answerable by
# running the same crops without it. Building the lane anyway keeps the
# comparison to one variable: the OCR cost is paid either way.
self.use_text = use_text
self.text_lane = TextLane.from_catalog(
_catalog_rows(stock_dir), stock_dir, ocr if ocr is not None else _ocr_engine()
)
@ -432,10 +392,6 @@ class LocalLaneAdapter(Lane):
margin: float, scale: str) -> Dict[str, Any]:
from identity_lanes import DecisionPolicy
if not self.use_text:
from identity import decide_identity
return decide_identity(dict(ranked), scale="local", min_score=accept)
read = self.text_lane.read(crop)
text_rank = sorted(
self.text_lane.scores_from_text(read).items(),
@ -649,8 +605,6 @@ def main() -> int:
parser.add_argument("--lanes", default="local,matcher,gallery,hybrid-verify")
parser.add_argument("--shortlist", type=int, default=5)
parser.add_argument("--accept", type=float, default=None, help="override the lane's own floor")
parser.add_argument("--no-text", action="store_true",
help="run the local lane without the text lane (E-3)")
parser.add_argument("--margin", type=float, default=None)
parser.add_argument("--live", action="store_true", help="also score at live resolution")
parser.add_argument("--json", help="write the report here")
@ -690,7 +644,7 @@ def main() -> int:
if "van" in wanted:
lanes["van"] = VanLane(args.stock_dir)
if "local" in wanted:
lanes["local"] = LocalLaneAdapter(args.stock_dir, use_text=not args.no_text)
lanes["local"] = LocalLaneAdapter(args.stock_dir)
if "gallery" in wanted or "hybrid-verify" in wanted:
lanes["gallery"] = GalleryLane(args.stock_dir, catalog_skus)
if "hybrid-verify" in wanted:

View file

@ -292,9 +292,7 @@ def main() -> None:
parser.add_argument("--epochs", type=int, default=60)
parser.add_argument("--views", type=int, default=2, help="enrolment views per class")
parser.add_argument("--augment", type=int, default=12, help="augmented copies per enrolment view")
parser.add_argument("--image", default=os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"data", "external-shelves", "wikimedia-commons"))
parser.add_argument("--image", default=os.path.expanduser("~/dorayaki-data/shelf-images"))
parser.add_argument("--stock", default=None,
help="train on a store's enrolled views instead of the milk shelf, "
"and write <stock>/identity_head.pt for the gallery to load")

View file

@ -30,11 +30,8 @@ DEFAULT_MIN_CONFIDENCE = 0.55
# `local` scale (identity_lanes.LocalLane). The value is already a margin over
# the runner-up, so the relative-margin test does not apply to it: a single
# absolute line is the whole gate. Kept equal to identity_lanes.LOCAL_ACCEPT,
# which is what the engine's DecisionPolicy uses; this copy exists for the
# evaluator's `--no-text` path, and two different lines would score the same
# crops two ways.
LOCAL_ACCEPT = 4.0
# absolute line is the whole gate.
LOCAL_ACCEPT = 2.0
def relative_confidence(top_score: float, runner_up_score: float) -> float:
@ -251,26 +248,6 @@ def decide_identity(
"message": message,
}
if scale == "van":
# Van's contract: the top SKU, always answered. No gate, no abstention -
# this is the baseline the decision is measured against, and it exists
# so the stream comparison is reproducible rather than asserted.
if top_score <= 0.0:
level, message, best = IDENTITY_REJECTED, "No matching evidence", None
else:
level, message, best = IDENTITY_ACCEPTED, "Always-argmax baseline", top_sku
return {
"status": "success" if level == IDENTITY_ACCEPTED else level,
"method": method,
"best_sku": best,
"score": round(top_score, 2),
"confidence": confidence,
"identity_level": level,
"ranked_candidates": ranked,
"details": top_details,
"message": message,
}
if top_score <= 0.0:
level = IDENTITY_REJECTED
message = "No matching evidence"

View file

@ -28,7 +28,7 @@ import os
import re
import sys
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
from typing import Any, Dict, List, Optional, Sequence, Tuple
current_dir = os.path.dirname(os.path.abspath(__file__))
if current_dir not in sys.path:
@ -43,14 +43,7 @@ from rebuild_catalog import bigrams, normalize_text, signature_bigrams, sku_of
# on the corrected image6 labels: 2.0 keeps 95% of shown labels correct at 59%
# coverage, 1.0 keeps 87% at 66%. The stricter line is the default because a
# wrong label on a shelf is worse than a missing one.
#
# 4.0 since 2026-09-29: the line was tuned on image6 alone, where every crop is
# a facing of a product the shelf actually holds. On 2297 crops from unrelated
# Wikimedia shelves, 2.0 accepted 25 of them on geometry alone (scores 2.26-4.81
# with no text overlap at all), a 1.1% far-unknown rate against G3's 1%. At 4.0
# that falls to 8 (0.35%) while image6 keeps 42 of 48 correct labels at 95.5%
# precision, which is the only region passing both G2 and G3 with coverage left.
LOCAL_ACCEPT = 4.0
LOCAL_ACCEPT = 2.0
# The text lane's own accept line: a bigram overlap this high, with this much
# margin over the runner-up, names the row on its own. Measured: the text lane
# picks the right SKU on 41-42 of the 55 facings where it reads anything.
@ -58,16 +51,12 @@ TEXT_ACCEPT = 0.5
TEXT_MARGIN = 0.15
# How many of the read's bigrams the winning row must actually contain. The
# ratio alone is not evidence: a two-pair read the row happens to contain scores
# 1.0, and "サソース" named sku_l08 that way. Two pairs was the old floor, and it
# is too low once the lane answers on unrelated shelves: on 2297 crops from
# Wikimedia milk, soy and snack shelves, 20 of 26 text-lane answers rested on
# exactly two shared bigrams ("避くて おいしい" -> sku_l12 via おい/いし), an
# 11.4% false accept rate against G3's 1%. Four is where the two sides separate
# once the local line is also at 4.0: false accepts fall to 8 (0.35%) while
# image6 keeps 42 of its 48 correct labels. It is a floor on evidence, not on
# the ratio, so a long read of shared brand copy still scores low and is still
# refused by the margin.
TEXT_MIN_SHARED = 4
# 1.0, and "サソース" named sku_l08 that way. Two pairs is the floor that keeps
# every correct text answer on image6 (40 of 40) while dropping the wrong ones
# from 6 to 2, taking shown-label precision from 87% to 95%. It is a floor on
# evidence, not on the ratio, so a long read of shared brand copy still scores
# low and is still refused by the margin.
TEXT_MIN_SHARED = 2
# Reference pyramid heights. A ~1000 px packshot against a 110-250 px crop is a
# 4-8x ratio, and ORB's 8x1.2 pyramid tops out at 3.6x, which is why ORB and
# BRISK find zero inliers at live resolution. Scaling each reference to the
@ -84,15 +73,6 @@ HUE_VALUE_MIN = 50
# Quantity tokens: a number with its unit, in either script. Cut whole, because
# the unit's own letters ("g", "ml", "%") are what survived a digits-only rule.
_QUANTITY = re.compile(r"\d+(?:\.\d+)?\s*(?:KG|G|ML|L|%|個|本|枚|入|袋|缶|パック)")
# A package's own size, in mass units. Only mass can contradict a package size:
# a percentage ("カロリー 50% カット") is a claim about the contents, not the
# package, and counting it made every low-calorie crop look like a 50 g jar.
_MASS = re.compile(r"(\d+(?:\.\d+)?)\s*(KG|G)", re.IGNORECASE)
# The range a retail package of this class falls in. Below it are fragments of a
# longer number ("20" read out of "200g"); above it are two numbers run together
# ("5210" out of "210g"). Both are OCR noise, not a different package, and
# either would veto a correct answer if taken at face value.
PACKAGE_MASS_RANGE = (100.0, 2000.0)
# How many surviving bigrams a read needs before it may name a product.
IDENTITY_BIGRAM_FLOOR = 3
@ -536,40 +516,6 @@ class DecisionPolicy:
return self.text_min_shared
return len(bigrams(read) & signature_bigrams(signature))
def _contradicts_size(self, read: str, sku: str) -> bool:
"""Does the read print a package size the row's name does not have?
The near-unknown case the text ranking cannot catch. A crop of a
Kewpie 450 g bag reads "マヨネーズ 450g", and every mayonnaise row in
the catalog explains "マヨネーズ" equally well, so the ranking ties at
1.0 and the strict-greater veto above never fires - the local lane's
answer stands. The size is the part that differs, and it is printed
plainly, so it is the part worth checking.
Only a mass in `PACKAGE_MASS_RANGE` counts. Outside it the read is
noise, not a package: "20" is a fragment of "200g" and "5210" is two
numbers run together, and vetoing on either would cost correct
answers. Measured on image6, this vetoes 3 false accepts at full
resolution and 1 at live, and no correct answer at either.
"""
signature = self.signatures.get(sku)
if signature is None:
return False
read_masses = {
float(number) for number, _unit in _MASS.findall(normalize_text(read))
}
plausible = {
mass for mass in read_masses
if PACKAGE_MASS_RANGE[0] <= mass <= PACKAGE_MASS_RANGE[1]
}
if not plausible:
return False
name_masses = {
float(number) for number, _unit in _MASS.findall(normalize_text(signature))
}
return not (read_masses & name_masses)
def decide(
self,
local_rank: Any,
@ -604,11 +550,6 @@ class DecisionPolicy:
and (text_top[1] - text_runner) >= self.text_margin
and carries_identity(text_read or "")
and self._shared_evidence(text_read or "", text_top[0]) >= self.text_min_shared
# A row the read contradicts on size cannot be named by that read
# either. Without this the veto above only moves the answer: the
# local lane's row is blocked and the text lane then names the same
# row, because "マヨネーズ" is what both of them matched.
and not self._contradicts_size(text_read or "", text_top[0])
else None
)
@ -621,8 +562,6 @@ class DecisionPolicy:
# artwork, so the geometry is real and only the words differ.
if text_top and text_top[0] != candidate and text_top[1] > local_text_floor(text, candidate):
answer = None
elif self._contradicts_size(text_read or "", candidate):
answer = None
else:
answer, source = candidate, "local"
if answer is None and text_names:

View file

@ -1301,10 +1301,6 @@ class LiveSession:
decision = DecisionPolicy(
signatures=text_lane.signatures if text_lane else None
).decide(list(scores.items()), text_rank, text_read=text_read)
elif scale == "van":
# Van's contract on the stream: the aggregate's top SKU, always
# answered. Same scorer as `matcher`, no gate.
decision = decide_identity(mean_scores, method=method, scale="van")
else:
decision = decide_identity(mean_scores, method=method, scale=scale)
track.candidates = [

View file

@ -170,34 +170,6 @@ def name_ocr_similarity(name: str, ocr_text: str) -> float:
return len(name_bigrams & bigrams(ocr_text)) / max(1, len(name_bigrams))
def best_orientation_text(ocr_engine: Any, image: np.ndarray, name: str = "") -> str:
"""OCR text from whichever of the four right-angle rotations matches best.
A packshot's identifying text is not always horizontal: the やまや
めんたいマヨネーズ packshot reads 明太手屋さんが only at 270 degrees, and
the upright pass returns three stray characters. Checking one orientation
refused a correct image, which is the worst failure this check has - it
rejects good data while claiming to validate it.
The winner is the rotation that explains the most of the catalog name, not
the one that reads the most characters: the upright pass on that packshot
returns stray kana that concatenate longer than the real read, so length
picks the wrong one. With no name to compare against, length is the only
signal left.
"""
rotations = (
image,
cv2.rotate(image, cv2.ROTATE_90_CLOCKWISE),
cv2.rotate(image, cv2.ROTATE_180),
cv2.rotate(image, cv2.ROTATE_90_COUNTERCLOCKWISE),
)
reads = [" ".join(line.text for line in ocr_engine.read(upscale(rotated)).lines)
for rotated in rotations]
if name:
return max(reads, key=lambda text: name_ocr_similarity(name, text))
return max(reads, key=lambda text: len(text.strip()))
def collect(source: str) -> Dict[str, List[str]]:
by_sku: Dict[str, List[str]] = {}
for name in sorted(os.listdir(source)):
@ -319,7 +291,7 @@ def audit(
continue
if ocr_engine is not None and names is not None:
try:
text = best_orientation_text(ocr_engine, image, names.get(sku, ""))
text = " ".join(line.text for line in ocr_engine.read(upscale(image)).lines)
except OcrUnavailable as error:
print(f"OCR skipped for {name}: {error}", file=sys.stderr)
else:
@ -345,25 +317,6 @@ def audit(
return kept, refused
def unattributed(source: str) -> List[str]:
"""SKUs whose views have no entry in the source's ATTRIBUTION.json.
Reported, not refused. A view with no recorded source is still a usable
reference - the six that predate the attribution file are independent of
the evaluation photo and their OCR matches their catalog names - but the
provenance is gone, and provenance is what lets a later audit tell a
re-sourced packshot from a leaked shelf crop. Naming them keeps the gap
from growing quietly.
"""
path = os.path.join(source, "ATTRIBUTION.json")
try:
with open(path) as handle:
recorded = json.load(handle)
except (OSError, ValueError):
recorded = {}
return sorted(sku for sku in collect(source) if sku not in recorded)
def text_discriminated_siblings(
kept: Dict[str, List[str]], source: str, names: Optional[Dict[str, str]] = None
) -> List[Dict[str, Any]]:
@ -582,8 +535,6 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
# and vetoes it from the CSV alone, so it is a coverage gap, not a refusal.
text_only = sorted(set(names) - set(kept))
siblings = text_discriminated_siblings(kept, args.source, names)
small = below_pyramid_floor(kept, args.source)
no_source = unattributed(args.source)
verdict = live_ready_verdict(kept, names, shelf_skus)
report = {
"source": args.source,
@ -597,7 +548,6 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
"text_only_skus": text_only,
"text_discriminated_siblings": siblings,
"below_pyramid_floor": small,
"unattributed_skus": no_source,
"completeness": verdict,
"skus": {sku: views for sku, views in sorted(kept.items())},
"refused": refused,
@ -621,9 +571,6 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
for row in small:
print(f" {row['sku']:<10} {row['view']:<20} {row['height']}px tall - "
"scores are not comparable with the rest; re-source this view")
if no_source:
print(f"\nno recorded source ({len(no_source)}): {', '.join(no_source)} - "
"usable, but provenance is missing; re-sourcing would close it")
if verdict["live_ready"] is None:
print(f"\ncompleteness: unknown - {verdict['reason']}")
elif verdict["live_ready"]:

View file

@ -136,19 +136,14 @@ class TextLaneSignatureTests(unittest.TestCase):
self.assertGreaterEqual(ranked[0][1], TEXT_ACCEPT)
self.assertGreaterEqual(ranked[0][1] - ranked[1][1], TEXT_MARGIN)
def test_a_thin_read_is_not_enough_to_name_a_row(self):
"""A ratio of 1.0 on a few pairs is not evidence.
def test_one_shared_pair_is_not_enough_to_name_a_row(self):
"""A ratio of 1.0 on a single pair is not evidence.
"XXイス" contains exactly one pair the catalog knows, and the row that
contains it scores 1.0 with a full margin over every other row. On
image6 that shape named sku_l08 from the read "サソース". The floor asks
how much of the read the row actually explains, so one pair and two
pairs are both refused while a real read of the name is accepted.
Two pairs used to be enough. It is not: on 2297 crops from unrelated
Wikimedia shelves, 20 of 26 text-lane answers rested on exactly two
shared bigrams - "避くて おいしい" named sku_l12 through おい/いし - for
an 11.4% false accept rate. The floor is 4.
image6 that shape named sku_l08 from the read "サソース". The floor
asks how much of the read the row actually explains, so one pair is
refused while the same read with two is accepted.
"""
from identity_lanes import DecisionPolicy, TextLane
@ -158,22 +153,16 @@ class TextLaneSignatureTests(unittest.TestCase):
]
lane = TextLane.from_catalog(rows, "no-such-dir", ocr_engine=None)
for thin_read in ("XXイス", "ZZソース"):
thin = sorted(lane.scores_from_text(thin_read).items(),
key=lambda kv: kv[1], reverse=True)
self.assertEqual(thin[0][1], 1.0, "the ratio alone cannot see the difference")
self.assertIsNone(
DecisionPolicy(signatures=lane.signatures).decide(
[], thin, text_read=thin_read
)["best_sku"],
f"{thin_read!r} shares too few pairs to name a row",
)
thin = sorted(lane.scores_from_text("XXイス").items(), key=lambda kv: kv[1], reverse=True)
self.assertEqual(thin[0][1], 1.0, "the ratio alone cannot see the difference")
self.assertIsNone(
DecisionPolicy(signatures=lane.signatures).decide([], thin, text_read="XXイス")["best_sku"]
)
enough = sorted(lane.scores_from_text("オイスター").items(),
key=lambda kv: kv[1], reverse=True)
enough = sorted(lane.scores_from_text("ZZソース").items(), key=lambda kv: kv[1], reverse=True)
self.assertEqual(
DecisionPolicy(signatures=lane.signatures).decide(
[], enough, text_read="オイスター"
[], enough, text_read="ZZソース"
)["best_sku"],
"sku_l08",
)
@ -627,11 +616,8 @@ class DecisionPolicyTests(unittest.TestCase):
the margin over the runner-up. Re-sorting by value names a different SKU
than the lane chose - here sku_b has the larger value but sku_a is the
lane's own first choice, and sku_a is what must be answered.
Both values clear LOCAL_ACCEPT, so the test is about the ordering and
not about the gate.
"""
decision = self.policy().decide([("sku_a", 4.5), ("sku_b", 5.0)], [])
decision = self.policy().decide([("sku_a", 3.0), ("sku_b", 5.0)], [])
self.assertEqual(decision["best_sku"], "sku_a")
def test_a_size_only_read_does_not_fill_in(self):
@ -646,56 +632,6 @@ class DecisionPolicyTests(unittest.TestCase):
)
self.assertIsNone(decision["best_sku"])
def test_a_read_printing_another_size_vetoes_the_local_answer(self):
"""The documented near-unknown: a Kewpie 450 g bag the catalog lacks.
The crop reads "マヨネーズ 450g", which every mayonnaise row explains
equally, so the text ranking ties at 1.0 and the strict-greater veto
cannot fire. The size is what differs, and the catalog's Kewpie rows
are 200 g, so the read contradicts the row the local lane chose.
"""
from identity_lanes import DecisionPolicy
policy = DecisionPolicy(signatures={"sku_a": "キューピー ハーフマヨネーズ 400g"})
decision = policy.decide(
[("sku_a", 5.0)], [("sku_a", 1.0)], text_read="ピーマヨネーズ 450g"
)
self.assertIsNone(decision["best_sku"])
def test_a_read_agreeing_on_size_does_not_veto(self):
"""The same read with the row's own size must still answer."""
from identity_lanes import DecisionPolicy
policy = DecisionPolicy(signatures={"sku_a": "キューピー ハーフマヨネーズ 400g"})
decision = policy.decide(
[("sku_a", 5.0)], [("sku_a", 1.0)], text_read="ピーマヨネーズ 400g"
)
self.assertEqual(decision["best_sku"], "sku_a")
def test_ocr_noise_around_a_size_does_not_veto(self):
"""A fragment or a run-together number is noise, not a package size.
Live reads of a correct 200 g row came back as "20" and "5210"; both
are outside the range a package of this class falls in, and vetoing on
either would cost a correct answer.
"""
from identity_lanes import DecisionPolicy
policy = DecisionPolicy(signatures={"sku_a": "キューピー ハーフマヨネーズ 200g"})
for read in ("アマニ油 20gs", "内臓脂訪を 滅与ず ッデュービーフィ5 210g", "n0gベ% 50"):
decision = policy.decide([("sku_a", 5.0)], [("sku_a", 1.0)], text_read=read)
self.assertEqual(decision["best_sku"], "sku_a", read)
def test_a_percentage_is_not_a_package_size(self):
"""A low-calorie claim must not read as a 50 g package."""
from identity_lanes import DecisionPolicy
policy = DecisionPolicy(signatures={"sku_a": "キューピー ハーフマヨネーズ 400g"})
decision = policy.decide(
[("sku_a", 5.0)], [("sku_a", 1.0)], text_read="カロリー 50% カット 400g"
)
self.assertEqual(decision["best_sku"], "sku_a")
def test_a_named_read_with_a_size_still_fills_in(self):
decision = self.policy().decide([], [("sku_b", 0.9)], text_read="コクうま 65%")
self.assertEqual(decision["best_sku"], "sku_b")

View file

@ -50,20 +50,3 @@ class IdentityRankingTests(unittest.TestCase):
self.assertEqual(ranked["identity_level"], IDENTITY_ACCEPTED)
self.assertEqual(ranked["best_sku"], "sku_a")
self.assertEqual(ranked["status"], "success")
def test_van_answers_what_the_gate_would_refuse(self):
# Van's contract is the baseline the decision is measured against, so it
# must answer exactly where the shipped gate abstains. A weak score and
# a close pair are both refused above; here both are named.
weak = decide_identity({"sku_a": 0.4, "sku_b": 0.0}, scale="van")
self.assertEqual(weak["identity_level"], IDENTITY_ACCEPTED)
self.assertEqual(weak["best_sku"], "sku_a")
close = decide_identity({"sku_a": 20.0, "sku_b": 19.5}, scale="van")
self.assertEqual(close["best_sku"], "sku_a")
def test_van_still_rejects_when_there_is_no_evidence(self):
# Always-argmax is not always-answer: with nothing to rank there is no
# argmax, and naming a SKU from an empty score set would be a fabrication.
ranked = decide_identity({}, scale="van")
self.assertEqual(ranked["identity_level"], IDENTITY_REJECTED)
self.assertIsNone(ranked["best_sku"])

View file

@ -184,95 +184,5 @@ class BadShelfListFailsBeforeTheSwapTests(unittest.TestCase):
self.assertFalse(os.path.exists(output), "the catalog was replaced before the check")
class OrientationTests(unittest.TestCase):
"""A packshot whose identifying text is not horizontal.
The やまや めんたいマヨネーズ packshot reads its brand only when rotated;
the upright pass returns stray kana. Checking one orientation refused a
correct image, which is the worst failure this check has - it rejects good
data while claiming to validate it.
"""
class FakeOcr:
"""Reads a fixed string per rotation.
Rotations are identified by aspect ratio, not by exact shape: the
caller upscales before OCR, so the fake sees scaled dimensions. The
upright and 180-degree passes keep the source's orientation (wide),
the two 90-degree passes swap it (tall). A corner marker cannot tell
them apart, because `cv2.rotate` carries the corner value through.
"""
name = "fake"
def __init__(self, by_orientation):
self.by_orientation = by_orientation
def read(self, image):
class Line:
def __init__(self, text):
self.text = text
class Read:
def __init__(self, text):
self.lines = [Line(part) for part in text.split("|") if part]
height, width = image.shape[:2]
key = "wide" if width > height else "tall"
return Read(self.by_orientation.get(key, ""))
def marked(self, height, width):
return np.zeros((height, width, 3), dtype="uint8")
def test_a_rotated_read_wins_when_it_matches_the_name(self):
"""The upright pass reads noise; the rotated pass reads the product."""
from rebuild_catalog import best_orientation_text
engine = self.FakeOcr({"wide": "つくつ|た", "tall": "明太手屋さんが|マヨネーズ"})
text = best_orientation_text(engine, self.marked(40, 60), "やまや めんたいマヨネーズタイプ 200g")
self.assertIn("明太手屋さんが", text)
def test_the_upright_read_wins_when_it_matches(self):
from rebuild_catalog import best_orientation_text
engine = self.FakeOcr({"wide": "ピュアセレクト|マヨネーズ|450g", "tall": "ノイズ"})
text = best_orientation_text(engine, self.marked(40, 60), "ピュアセレクト マヨネーズ 450g")
self.assertIn("ピュアセレクト", text)
def test_without_a_name_the_longest_read_wins(self):
from rebuild_catalog import best_orientation_text
engine = self.FakeOcr({"wide": "ab", "tall": "abcdefgh"})
self.assertEqual(best_orientation_text(engine, self.marked(40, 60)), "abcdefgh")
def test_audit_keeps_a_view_whose_name_only_reads_rotated(self):
"""The regression: `audit` must try every orientation before refusing.
This is the boundary that matters. `best_orientation_text` passing its
own unit test proves nothing about enrollment - the bug was that
`audit` called a single-orientation read, so a correct packshot whose
brand text runs vertically was refused as a name/OCR mismatch.
"""
from rebuild_catalog import audit
root = tempfile.mkdtemp(prefix="orient-")
# Wider than tall, so the upright pass is the "wide" read (noise) and
# only a 90-degree rotation produces the "tall" read that matches.
cv2.imwrite(os.path.join(root, "sku_x1.jpg"), self.marked(300, 400))
engine = self.FakeOcr({"wide": "つくつ|た", "tall": "キユーピー|エッグケア|305g"})
kept, refused = audit(root, min_views=1, names={"sku_x1": "キューピー エッグケア 305g"},
ocr_engine=engine)
self.assertEqual(kept, {"sku_x1": ["sku_x1.jpg"]}, refused)
self.assertEqual(refused, [])
def test_audit_still_refuses_a_view_the_name_never_matches(self):
"""The check must keep its teeth: a wrong product is still refused."""
from rebuild_catalog import audit
root = tempfile.mkdtemp(prefix="orient-bad-")
cv2.imwrite(os.path.join(root, "sku_x1.jpg"), self.marked(400, 300))
engine = self.FakeOcr({"wide": "キューピー|マヨネーズ", "tall": "キューピー|マヨネーズ"})
kept, refused = audit(root, min_views=1, names={"sku_x1": "やまや めんたいマヨネーズタイプ 200g"},
ocr_engine=engine)
self.assertEqual(kept, {})
self.assertEqual(len(refused), 1, refused)
self.assertIn("name/OCR", refused[0]["reason"])
if __name__ == "__main__":
unittest.main()

View file

@ -1,34 +0,0 @@
# Recognition data
Everything the identity work needs to be reproducible: sourced packshots,
evaluation runs, and the audits that produced them. Nothing here is ephemeral;
a run that matters is written here, not to a temp directory.
## `packshots/source/`
Front packshots as downloaded, before enrollment. `ATTRIBUTION.json` records the
source URL, the product as printed, and the manufacturer for each. `ocr-reads.json`
holds the OCR text each image produced, so a name/OCR refusal can be re-checked
without re-running Vision.
`rebuild_catalog.py` reads this directory and writes a catalog:
.venv/bin/python code/rebuild_catalog.py --source data/packshots/source \
--output stock-lopia-clean --csv stock-lopia/stock.csv --min-views 1 --replace
## `evaluations/`
One directory per session. Each holds the raw artifacts a claim was computed
from, so a number in DECISION.md can be traced back to the run that produced it.
| Directory | Contents |
|---|---|
| `stream/` | WebSocket track dumps from `stream_client.py`, scored by `score_stream.py`. |
| `still/` | `/v1/recognize` responses for the still-image path. |
| `catalog-audit/` | `rebuild_catalog.py --json` reports. |
| `scratch/` | Intermediate images (rotations, crops) kept because they document a finding. |
Scoring a stream dump:
.venv/bin/python code/score_stream.py data/evaluations/<session>/stream/<file>.json \
--labels eval/labels/image6.json --stock-dir stock-lopia-clean

View file

@ -1,76 +0,0 @@
{
"source": "data/packshots/source",
"output": "stock-lopia-clean",
"min_width": 120,
"min_height": 160,
"min_views": 1,
"duplicate_cosine": 0.98,
"min_name_ocr": 0.7,
"ocr": "vision",
"text_only_skus": [
"sku_l04",
"sku_l07",
"sku_l08",
"sku_l12"
],
"text_discriminated_siblings": [
{
"sku": "sku_l09",
"view": "sku_l09.jpg",
"sibling_sku": "sku_l10",
"sibling_view": "sku_l10.jpg",
"cosine": 0.9817,
"name_similarity": 0.333
}
],
"below_pyramid_floor": [],
"completeness": {
"live_ready": null,
"reason": "no --shelf-tags given; the completeness rule needs the shelf's SKU list",
"enrolled": 8,
"text_only": [
"sku_l04",
"sku_l07",
"sku_l08",
"sku_l12"
]
},
"skus": {
"sku_l01": [
"sku_l01.jpg"
],
"sku_l02": [
"sku_l02.jpg"
],
"sku_l03": [
"sku_l03.jpg"
],
"sku_l05": [
"sku_l05.jpg"
],
"sku_l06": [
"sku_l06.jpg"
],
"sku_l09": [
"sku_l09.jpg"
],
"sku_l10": [
"sku_l10.jpg"
],
"sku_l11": [
"sku_l11.jpg"
]
},
"refused": [
{
"sku": "sku_l04",
"view": "sku_l04.jpg",
"reason": "name/OCR 0.00 below 0.70: 明太手屋さんが つくつ た コ"
},
{
"sku": "sku_l07",
"view": "sku_l07.jpg",
"reason": "name/OCR 0.57 below 0.70: ラッキー マヨネーズ 調味料(アミノ酸)不使用"
}
]
}

View file

@ -1,221 +0,0 @@
{
"image": "add/image6.jpg",
"stock_dir": "stock-lopia-clean",
"labels": "eval/labels/image6.json",
"provenance": {
"catalog_hash": "3f1653d9d962073af96121250006b9b6d86113556797bbee65de68b60e87a64d",
"labels_hash": "5aabf029921ced767d4e14a18e8bccb55812dd974852c56e0931a98fa10366cf",
"image_hash": "a08b89ba37d27c9d5a81bcd56bcd042e6baf54d2f182bbaaf47e97f14c9cd525",
"code_revision": "68061635d9fef3097609945bfd9ac1935791b398"
},
"facings": 111,
"leakage": {
"excluded": 0,
"labels": []
},
"hub_share_limit": 0.3,
"enrollment": {
"catalog_skus": [
"sku_l01",
"sku_l02",
"sku_l03",
"sku_l04",
"sku_l05",
"sku_l06",
"sku_l07",
"sku_l08",
"sku_l09",
"sku_l10",
"sku_l11",
"sku_l12"
],
"enrolled_skus": [
"sku_l01",
"sku_l02",
"sku_l03",
"sku_l05",
"sku_l06",
"sku_l09",
"sku_l10",
"sku_l11"
],
"unenrolled_skus": {
"sku_l07": 6,
"sku_l04": 1,
"sku_l08": 1
},
"facings_without_enrollment": 8,
"scored_facings": 109
},
"results": {
"full": [
{
"lane": "local",
"accept": 2.0,
"margin": 0.15,
"in_catalog": 61,
"out_catalog": 48,
"enrolled": 53,
"correct": 48,
"wrong": 9,
"rejected": 52,
"catalog_ceiling": 0.8689,
"identity_ceiling": 0.9245,
"decision_ceiling": 0.8113,
"end_to_end_coverage": 0.7869,
"false_accepts": 9,
"unknown_false_accepts": 8,
"unenrolled_false_accepts": 0,
"false_accept_rate": 0.1667,
"near_unknown_false_accept_rate": 0.0,
"precision": 0.8421,
"max_sku_share": 0.2281,
"hub_sku": "sku_l09",
"hub_alarm": false,
"histogram": {
"sku_l09": 13,
"sku_l11": 10,
"sku_l10": 9,
"sku_l01": 5,
"sku_l03": 5,
"sku_l07": 5,
"sku_l06": 4,
"sku_l02": 3,
"sku_l05": 3
},
"per_sku": {
"sku_l01": {
"correct": 5,
"total": 5
},
"sku_l02": {
"correct": 2,
"total": 4
},
"sku_l03": {
"correct": 4,
"total": 4
},
"sku_l04": {
"correct": 0,
"total": 1
},
"sku_l05": {
"correct": 3,
"total": 3
},
"sku_l06": {
"correct": 4,
"total": 4
},
"sku_l07": {
"correct": 5,
"total": 6
},
"sku_l08": {
"correct": 0,
"total": 1
},
"sku_l09": {
"correct": 11,
"total": 13
},
"sku_l10": {
"correct": 9,
"total": 11
},
"sku_l11": {
"correct": 5,
"total": 9
}
},
"ms_per_crop": 26.7
}
],
"live": [
{
"lane": "local",
"accept": 2.0,
"margin": 0.15,
"in_catalog": 61,
"out_catalog": 48,
"enrolled": 53,
"correct": 41,
"wrong": 4,
"rejected": 64,
"catalog_ceiling": 0.8689,
"identity_ceiling": 0.8491,
"decision_ceiling": 0.6604,
"end_to_end_coverage": 0.6721,
"false_accepts": 4,
"unknown_false_accepts": 3,
"unenrolled_false_accepts": 0,
"false_accept_rate": 0.0625,
"near_unknown_false_accept_rate": 0.0,
"precision": 0.9111,
"max_sku_share": 0.2,
"hub_sku": "sku_l09",
"hub_alarm": false,
"histogram": {
"sku_l09": 9,
"sku_l10": 9,
"sku_l11": 7,
"sku_l07": 6,
"sku_l01": 4,
"sku_l03": 3,
"sku_l05": 3,
"sku_l06": 2,
"sku_l02": 1,
"sku_l04": 1
},
"per_sku": {
"sku_l01": {
"correct": 4,
"total": 5
},
"sku_l02": {
"correct": 1,
"total": 4
},
"sku_l03": {
"correct": 3,
"total": 4
},
"sku_l04": {
"correct": 1,
"total": 1
},
"sku_l05": {
"correct": 3,
"total": 3
},
"sku_l06": {
"correct": 2,
"total": 4
},
"sku_l07": {
"correct": 5,
"total": 6
},
"sku_l08": {
"correct": 0,
"total": 1
},
"sku_l09": {
"correct": 9,
"total": 13
},
"sku_l10": {
"correct": 9,
"total": 11
},
"sku_l11": {
"correct": 4,
"total": 9
}
},
"ms_per_crop": 20.9
}
]
}
}

View file

@ -1,70 +0,0 @@
{
"source": "data/packshots/source",
"output": "/tmp/lopia-audit",
"min_width": 120,
"min_height": 160,
"min_views": 1,
"duplicate_cosine": 0.98,
"min_name_ocr": 0.7,
"ocr": "vision",
"text_only_skus": [
"sku_l04",
"sku_l07",
"sku_l08",
"sku_l09",
"sku_l10",
"sku_l12"
],
"text_discriminated_siblings": [],
"below_pyramid_floor": [],
"completeness": {
"live_ready": null,
"reason": "no --shelf-tags given; the completeness rule needs the shelf's SKU list",
"enrolled": 6,
"text_only": [
"sku_l04",
"sku_l07",
"sku_l08",
"sku_l09",
"sku_l10",
"sku_l12"
]
},
"skus": {
"sku_l01": [
"sku_l01.jpg"
],
"sku_l02": [
"sku_l02.jpg"
],
"sku_l03": [
"sku_l03.jpg"
],
"sku_l05": [
"sku_l05.jpg"
],
"sku_l06": [
"sku_l06.jpg"
],
"sku_l11": [
"sku_l11.jpg"
]
},
"refused": [
{
"sku": "sku_l04",
"view": "sku_l04.jpg",
"reason": "name/OCR 0.00 below 0.70: 明太手屋さんが つくつ た コ"
},
{
"sku": "sku_l07",
"view": "sku_l07.jpg",
"reason": "name/OCR 0.57 below 0.70: ラッキー マヨネーズ 調味料(アミノ酸)不使用"
},
{
"sku": "sku_l09",
"view": "sku_l09.jpg",
"reason": "name/OCR 0.05 below 0.70: キューピーマヨネーズ KEWPIE MAYONNAISE ® 450g"
}
]
}

View file

@ -1,27 +0,0 @@
"""Which facings are falsely accepted, and as what."""
import sys, cv2
sys.path.insert(0, "code")
from eval_identity import LocalLaneAdapter, load_labels, at_live_resolution, leaked, UNKNOWN, crop_of
from identity_lanes import DecisionPolicy
payload = load_labels("eval/labels/image6.json")
image = cv2.imread("add/image6.jpg")
facings = [f for f in payload["facings"] if f["label"] != "exclude"]
facings = [f for f in facings if leaked(image, f["box"], "stock-lopia-clean") is None]
enrolled = {"sku_l01","sku_l02","sku_l03","sku_l05","sku_l06","sku_l09","sku_l10","sku_l11"}
for live in (False, True):
picture = at_live_resolution(image) if live else image
scaled = facings if not live else [
{**f, "box": [int(round(v * picture.shape[1] / image.shape[1])) for v in f["box"]]}
for f in facings]
lane = LocalLaneAdapter("stock-lopia-clean")
print(f"=== {'live' if live else 'full'} ===")
for f in scaled:
crop = crop_of(picture, f["box"])
if crop.size == 0: continue
ranked = lane.rank(crop)
d = lane.decide(crop, ranked, accept=2.0, margin=0.0, scale="local")
ans = d.get("best_sku") if d.get("identity_level") == "accepted" else None
if ans and ans != f["label"]:
print(f" #{f['index']:>3} truth={f['label']:<9} answered={ans:<9} enrolled={f['label'] in enrolled}")

View file

@ -1,54 +0,0 @@
"""What the falsely-accepted crops actually read, and what the text lane scores.
The advisory says crops 82-97 are Kewpie Mayonnaise 450g - a product the
catalog does not hold - and that no catalog row can therefore win the text
ranking, so the veto at DecisionPolicy.decide never fires. This prints the
reads and the scores that claim depends on.
"""
import csv
import sys
import cv2
import numpy as np
sys.path.insert(0, "code")
from identity_lanes import TextLane, carries_identity # noqa: E402
from ocr import build_ocr_engine # noqa: E402
INDICES = [73, 82, 83, 84, 86, 94, 95, 96, 97]
rows = []
with open("stock-lopia-clean/stock.csv", encoding="utf-8") as handle:
for record in csv.reader(handle):
if len(record) >= 2 and record[0].startswith("sku_"):
rows.append({"sku_id": record[0], "product_name": record[1]})
engine = build_ocr_engine("vision")
lane = TextLane.from_catalog(rows, "stock-lopia-clean", ocr_engine=engine)
labels = {f["index"]: f for f in __import__("json").load(open("eval/labels/image6.json"))["facings"]}
image = cv2.imread("add/image6.jpg")
tiles = []
for index in INDICES:
facing = labels[index]
x1, y1, x2, y2 = facing["box"]
crop = image[y1:y2, x1:x2]
read = lane.read(crop)
scores = lane.scores_from_text(read)
top = sorted(scores.items(), key=lambda kv: -kv[1])[:3]
print(f"#{index:>3} truth={facing['label']:<9} carries={carries_identity(read)}")
print(f" read: {read[:110]}")
print(f" text top: {[(s, round(v, 3)) for s, v in top]}")
height, width = crop.shape[:2]
scale = 260.0 / max(1, height)
resized = cv2.resize(crop, (max(1, int(width * scale)), 260), interpolation=cv2.INTER_CUBIC)
canvas = np.full((300, 220, 3), 255, np.uint8)
canvas[30:290, : min(resized.shape[1], 220)] = resized[:, :220]
cv2.putText(canvas, str(index), (6, 22), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)
tiles.append(canvas)
grid = np.vstack([np.hstack(tiles[:5]), np.hstack(tiles[5:])])
cv2.imwrite("data/evaluations/2026-09-29-session/scratch/fa_tiles.jpg", grid)
print("tile:", grid.shape)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 360 KiB

View file

@ -1,71 +0,0 @@
"""Mass-only size contradiction, with full reads.
The first pass counted every quantity token, including percentages, so a crop
reading "カロリー 50% カット" looked like a 50 g package. Only mass units can
contradict a package size, so this restricts to g/kg and prints the whole read
for every accepted facing that carries one.
"""
import csv
import json
import re
import sys
import cv2
sys.path.insert(0, "code")
from eval_identity import LocalLaneAdapter, at_live_resolution, crop_of, leaked # noqa: E402
from identity_lanes import TextLane, normalize_text # noqa: E402
from ocr import build_ocr_engine # noqa: E402
MASS = re.compile(r"(\d+(?:\.\d+)?)\s*(KG|G)", re.IGNORECASE)
# Package sizes this product class actually comes in. Below it are OCR fragments
# of a longer number ("20" from 200); above it are concatenations ("5210").
PLAUSIBLE = (100.0, 2000.0)
def masses(text: str):
return {float(number) for number, _unit in MASS.findall(normalize_text(text))}
rows = []
with open("stock-lopia-clean/stock.csv", encoding="utf-8") as handle:
for record in csv.reader(handle):
if len(record) >= 2 and record[0].startswith("sku_"):
rows.append({"sku_id": record[0], "product_name": record[1]})
names = {row["sku_id"]: row["product_name"] for row in rows}
engine = build_ocr_engine("vision")
text_lane = TextLane.from_catalog(rows, "stock-lopia-clean", ocr_engine=engine)
local_lane = LocalLaneAdapter("stock-lopia-clean")
payload = json.load(open("eval/labels/image6.json"))
image = cv2.imread("add/image6.jpg")
facings = [f for f in payload["facings"] if f["label"] != "exclude"]
facings = [f for f in facings if leaked(image, f["box"], "stock-lopia-clean") is None]
for live in (False, True):
picture = at_live_resolution(image) if live else image
scaled = facings if not live else [
{**f, "box": [int(round(v * picture.shape[1] / image.shape[1])) for v in f["box"]]}
for f in facings]
print(f"=== {'live' if live else 'full'} ===")
for facing in scaled:
crop = crop_of(picture, facing["box"])
if crop.size == 0:
continue
ranked = local_lane.rank(crop)
decision = local_lane.decide(crop, ranked, accept=2.0, margin=0.0, scale="local")
if decision.get("identity_level") != "accepted":
continue
answer = decision.get("best_sku")
read = text_lane.read(crop)
read_masses = masses(read)
name_masses = masses(names.get(answer, ""))
if not read_masses or not name_masses:
continue
plausible = {m for m in read_masses if PLAUSIBLE[0] <= m <= PLAUSIBLE[1]}
veto = bool(plausible) and not (read_masses & name_masses)
correct = "correct" if answer == facing["label"] else "WRONG"
print(f" #{facing['index']:>3} {'VETO' if veto else 'keep'} {correct:<7} "
f"answer={answer:<9} read_m={sorted(read_masses)} name_m={sorted(name_masses)}")
print(f" read: {read[:120]}")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

View file

@ -1,28 +0,0 @@
{
"endpoint": "/v1/recognize",
"image": "add/image6.jpg",
"catalog": "stock-lopia-clean",
"gates": "LOCAL_ACCEPT=4.0 TEXT_MIN_SHARED=4",
"still": {
"detections": 111,
"named": 44,
"matched": 44,
"correct": 42,
"wrong": 2,
"precision": 0.955
},
"stream": {
"seconds": 20,
"pan": 12,
"tracks_seen": 165,
"tracks_labelled": 62,
"label_changes": 0,
"unexplained_label_changes": 0,
"facings_named": "17/27",
"precision": 0.84,
"wrong": 0,
"unenrolled_false_accepts": 0,
"unknown_false_accepts": 8
},
"note": "Final state with every catalog correction and both calibrated accept lines live."
}

View file

@ -1,24 +0,0 @@
{
"endpoint": "/v1/recognize",
"image": "add/image6.jpg",
"catalog": "stock-lopia-clean",
"gates": "LOCAL_ACCEPT=4.0 TEXT_MIN_SHARED=4",
"detections": 111,
"named": 44,
"matched": 44,
"correct": 42,
"wrong": 2,
"precision": 0.955,
"wrong_detail": [
"sku_l07 vs unknown",
"sku_l03 vs unknown"
],
"previous": {
"gates": "2.0/2",
"named": 53,
"correct": 48,
"wrong": 5,
"precision": 0.906
},
"note": "Live still endpoint, box-IoU matched to the corrected labels. The stricter gates trade 9 names for 3 wrong labels."
}

File diff suppressed because one or more lines are too long

View file

@ -1,23 +0,0 @@
{
"endpoint": "/v1/recognize",
"image": "add/image6.jpg",
"catalog": "stock-lopia-clean",
"catalog_version": "b862c55f4623",
"detections": 111,
"named": 53,
"named_without_product_info": 0,
"view_suffixed_ids": [],
"elapsed_s": 2.38,
"by_sku": {
"sku_l10": 9,
"sku_l11": 5,
"sku_l01": 5,
"sku_l06": 4,
"sku_l09": 13,
"sku_l07": 6,
"sku_l02": 3,
"sku_l03": 5,
"sku_l05": 3
},
"note": "Final state: l06 current package enrolled (4/4 facings named, was 1/4 at live), l10/l11 400g, l12 renamed. l12 not named: no enrolled view."
}

View file

@ -1,214 +0,0 @@
{
"image": "add/image6.jpg",
"stock_dir": "stock-lopia-clean",
"labels": "eval/labels/image6.json",
"provenance": {
"catalog_hash": "10f55211435aa64c0ed4ca24a7b902ce4ed8a30299409680942b78ec9b3d08c8",
"labels_hash": "5aabf029921ced767d4e14a18e8bccb55812dd974852c56e0931a98fa10366cf",
"image_hash": "a08b89ba37d27c9d5a81bcd56bcd042e6baf54d2f182bbaaf47e97f14c9cd525",
"code_revision": "b6c87c45ea2aa3ed59f5764274341f3ffed30fe0"
},
"facings": 111,
"leakage": {
"excluded": 0,
"labels": []
},
"hub_share_limit": 0.3,
"enrollment": {
"catalog_skus": [
"sku_l01",
"sku_l02",
"sku_l03",
"sku_l04",
"sku_l05",
"sku_l06",
"sku_l07",
"sku_l08",
"sku_l09",
"sku_l10",
"sku_l11",
"sku_l12"
],
"enrolled_skus": [
"sku_l01",
"sku_l02",
"sku_l03",
"sku_l05",
"sku_l06",
"sku_l09",
"sku_l11"
],
"unenrolled_skus": {
"sku_l10": 11,
"sku_l07": 6,
"sku_l04": 1,
"sku_l08": 1
},
"facings_without_enrollment": 19,
"scored_facings": 109
},
"results": {
"full": [
{
"lane": "van",
"accept": 1.0,
"margin": 0.15,
"in_catalog": 61,
"out_catalog": 48,
"enrolled": 42,
"correct": 32,
"wrong": 77,
"rejected": 0,
"catalog_ceiling": 0.6885,
"identity_ceiling": 0.7619,
"decision_ceiling": 0.7619,
"end_to_end_coverage": 0.5246,
"false_accepts": 77,
"unknown_false_accepts": 48,
"unenrolled_false_accepts": 19,
"false_accept_rate": 1.0,
"near_unknown_false_accept_rate": 1.0,
"precision": 0.2936,
"max_sku_share": 0.2569,
"hub_sku": "sku_l09",
"hub_alarm": false,
"histogram": {
"sku_l09": 28,
"sku_l05": 19,
"sku_l02": 15,
"sku_l03": 14,
"sku_l06": 13,
"sku_l11": 12,
"sku_l01": 8
},
"per_sku": {
"sku_l01": {
"correct": 5,
"total": 5
},
"sku_l02": {
"correct": 4,
"total": 4
},
"sku_l03": {
"correct": 4,
"total": 4
},
"sku_l04": {
"correct": 0,
"total": 1
},
"sku_l05": {
"correct": 3,
"total": 3
},
"sku_l06": {
"correct": 0,
"total": 4
},
"sku_l07": {
"correct": 0,
"total": 6
},
"sku_l08": {
"correct": 0,
"total": 1
},
"sku_l09": {
"correct": 13,
"total": 13
},
"sku_l10": {
"correct": 0,
"total": 11
},
"sku_l11": {
"correct": 3,
"total": 9
}
},
"ms_per_crop": 15.4
},
{
"lane": "local",
"accept": 2.0,
"margin": 0.15,
"in_catalog": 61,
"out_catalog": 48,
"enrolled": 42,
"correct": 36,
"wrong": 5,
"rejected": 68,
"catalog_ceiling": 0.6885,
"identity_ceiling": 0.9286,
"decision_ceiling": 0.8571,
"end_to_end_coverage": 0.5902,
"false_accepts": 5,
"unknown_false_accepts": 4,
"unenrolled_false_accepts": 0,
"false_accept_rate": 0.0833,
"near_unknown_false_accept_rate": 0.0,
"precision": 0.878,
"max_sku_share": 0.2927,
"hub_sku": "sku_l09",
"hub_alarm": false,
"histogram": {
"sku_l09": 12,
"sku_l11": 8,
"sku_l03": 6,
"sku_l01": 5,
"sku_l07": 4,
"sku_l02": 3,
"sku_l05": 3
},
"per_sku": {
"sku_l01": {
"correct": 5,
"total": 5
},
"sku_l02": {
"correct": 2,
"total": 4
},
"sku_l03": {
"correct": 4,
"total": 4
},
"sku_l04": {
"correct": 0,
"total": 1
},
"sku_l05": {
"correct": 3,
"total": 3
},
"sku_l06": {
"correct": 0,
"total": 4
},
"sku_l07": {
"correct": 4,
"total": 6
},
"sku_l08": {
"correct": 0,
"total": 1
},
"sku_l09": {
"correct": 12,
"total": 13
},
"sku_l10": {
"correct": 0,
"total": 11
},
"sku_l11": {
"correct": 6,
"total": 9
}
},
"ms_per_crop": 26.6
}
]
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,554 +0,0 @@
{
"windows": [
[
0,
640,
1280,
1360
],
[
12,
640,
1292,
1360
],
[
24,
640,
1304,
1360
],
[
36,
640,
1316,
1360
],
[
48,
640,
1328,
1360
],
[
60,
640,
1340,
1360
],
[
72,
640,
1352,
1360
],
[
84,
640,
1364,
1360
],
[
96,
640,
1376,
1360
],
[
108,
640,
1388,
1360
],
[
120,
640,
1400,
1360
],
[
132,
640,
1412,
1360
],
[
144,
640,
1424,
1360
],
[
156,
640,
1436,
1360
],
[
168,
640,
1448,
1360
],
[
180,
640,
1460,
1360
],
[
192,
640,
1472,
1360
],
[
204,
640,
1484,
1360
],
[
216,
640,
1496,
1360
],
[
220,
640,
1500,
1360
],
[
216,
640,
1496,
1360
],
[
204,
640,
1484,
1360
],
[
192,
640,
1472,
1360
],
[
180,
640,
1460,
1360
],
[
168,
640,
1448,
1360
],
[
156,
640,
1436,
1360
],
[
144,
640,
1424,
1360
],
[
132,
640,
1412,
1360
],
[
120,
640,
1400,
1360
],
[
108,
640,
1388,
1360
],
[
96,
640,
1376,
1360
],
[
84,
640,
1364,
1360
],
[
72,
640,
1352,
1360
],
[
60,
640,
1340,
1360
],
[
48,
640,
1328,
1360
],
[
36,
640,
1316,
1360
],
[
24,
640,
1304,
1360
],
[
12,
640,
1292,
1360
],
[
0,
640,
1280,
1360
],
[
12,
640,
1292,
1360
],
[
24,
640,
1304,
1360
],
[
36,
640,
1316,
1360
],
[
48,
640,
1328,
1360
],
[
60,
640,
1340,
1360
],
[
72,
640,
1352,
1360
],
[
84,
640,
1364,
1360
],
[
96,
640,
1376,
1360
],
[
108,
640,
1388,
1360
],
[
120,
640,
1400,
1360
],
[
132,
640,
1412,
1360
],
[
144,
640,
1424,
1360
],
[
156,
640,
1436,
1360
],
[
168,
640,
1448,
1360
],
[
180,
640,
1460,
1360
],
[
192,
640,
1472,
1360
],
[
204,
640,
1484,
1360
],
[
216,
640,
1496,
1360
],
[
220,
640,
1500,
1360
],
[
216,
640,
1496,
1360
],
[
204,
640,
1484,
1360
],
[
192,
640,
1472,
1360
],
[
180,
640,
1460,
1360
],
[
168,
640,
1448,
1360
],
[
156,
640,
1436,
1360
],
[
144,
640,
1424,
1360
],
[
132,
640,
1412,
1360
],
[
120,
640,
1400,
1360
],
[
108,
640,
1388,
1360
],
[
96,
640,
1376,
1360
],
[
84,
640,
1364,
1360
],
[
72,
640,
1352,
1360
],
[
60,
640,
1340,
1360
],
[
48,
640,
1328,
1360
],
[
36,
640,
1316,
1360
],
[
24,
640,
1304,
1360
],
[
12,
640,
1292,
1360
],
[
0,
640,
1280,
1360
],
[
12,
640,
1292,
1360
],
[
24,
640,
1304,
1360
],
[
36,
640,
1316,
1360
],
[
48,
640,
1328,
1360
],
[
60,
640,
1340,
1360
],
[
72,
640,
1352,
1360
],
[
84,
640,
1364,
1360
],
[
96,
640,
1376,
1360
],
[
108,
640,
1388,
1360
],
[
120,
640,
1400,
1360
],
[
132,
640,
1412,
1360
],
[
144,
640,
1424,
1360
],
[
156,
640,
1436,
1360
],
[
168,
640,
1448,
1360
]
],
"source": "add/image6.jpg",
"pace": "send",
"seconds": 8.1,
"tracks": []
}

View file

@ -1,721 +0,0 @@
{
"source": "add/image6.jpg",
"pace": "result",
"seconds": 12.0,
"tracks": [
{
"track_id": 1,
"sku_id": "sku_l09",
"label_box": [
580,
122,
666,
332
],
"label_frame_id": 0,
"result_frame_id": 108,
"inherited": true,
"restarts": 2
},
{
"track_id": 2,
"sku_id": "sku_l09",
"label_box": [
659,
760,
746,
972
],
"label_frame_id": 71,
"result_frame_id": 81,
"inherited": true,
"restarts": 2
},
{
"track_id": 4,
"sku_id": "sku_l09",
"label_box": [
586,
119,
674,
333
],
"label_frame_id": 0,
"result_frame_id": 115,
"inherited": true,
"restarts": 5
},
{
"track_id": 5,
"sku_id": "sku_l09",
"label_box": [
512,
755,
604,
972
],
"label_frame_id": 98,
"result_frame_id": 114,
"inherited": true,
"restarts": 0
},
{
"track_id": 6,
"sku_id": "sku_l09",
"label_box": [
937,
764,
1036,
975
],
"label_frame_id": 16,
"result_frame_id": 25,
"inherited": true,
"restarts": 0
},
{
"track_id": 8,
"sku_id": "sku_l09",
"label_box": [
291,
754,
389,
971
],
"label_frame_id": 107,
"result_frame_id": 114,
"inherited": true,
"restarts": 0
},
{
"track_id": 9,
"sku_id": "sku_l09",
"label_box": [
459,
1068,
555,
1251
],
"label_frame_id": 67,
"result_frame_id": 89,
"inherited": true,
"restarts": 0
},
{
"track_id": 11,
"sku_id": "sku_l09",
"label_box": [
688,
1057,
789,
1142
],
"label_frame_id": 50,
"result_frame_id": 59,
"inherited": false,
"restarts": 1
},
{
"track_id": 12,
"sku_id": "sku_l09",
"label_box": [
1254,
1053,
1334,
1244
],
"label_frame_id": 61,
"result_frame_id": 69,
"inherited": true,
"restarts": 1
},
{
"track_id": 13,
"sku_id": "sku_l09",
"label_box": [
1079,
762,
1176,
973
],
"label_frame_id": 89,
"result_frame_id": 115,
"inherited": true,
"restarts": 1
},
{
"track_id": 14,
"sku_id": "sku_l09",
"label_box": [
1277,
1065,
1345,
1239
],
"label_frame_id": 52,
"result_frame_id": 63,
"inherited": true,
"restarts": 2
},
{
"track_id": 15,
"sku_id": "sku_l09",
"label_box": [
798,
765,
966,
971
],
"label_frame_id": 83,
"result_frame_id": 114,
"inherited": true,
"restarts": 0
},
{
"track_id": 16,
"sku_id": "sku_l09",
"label_box": [
430,
1070,
543,
1250
],
"label_frame_id": 99,
"result_frame_id": 113,
"inherited": false,
"restarts": 1
},
{
"track_id": 17,
"sku_id": "sku_l10",
"label_box": [
878,
1146,
970,
1245
],
"label_frame_id": 90,
"result_frame_id": 106,
"inherited": false,
"restarts": 0
},
{
"track_id": 18,
"sku_id": "sku_l09",
"label_box": [
1128,
763,
1271,
973
],
"label_frame_id": 99,
"result_frame_id": 112,
"inherited": false,
"restarts": 10
},
{
"track_id": 19,
"sku_id": "sku_l09",
"label_box": [
680,
1058,
780,
1142
],
"label_frame_id": 97,
"result_frame_id": 110,
"inherited": false,
"restarts": 2
},
{
"track_id": 21,
"sku_id": "sku_l10",
"label_box": [
673,
1146,
773,
1247
],
"label_frame_id": 98,
"result_frame_id": 111,
"inherited": false,
"restarts": 0
},
{
"track_id": 22,
"sku_id": "sku_l09",
"label_box": [
287,
775,
347,
969
],
"label_frame_id": 2,
"result_frame_id": 18,
"inherited": true,
"restarts": 0
},
{
"track_id": 23,
"sku_id": "sku_l10",
"label_box": [
917,
1146,
999,
1244
],
"label_frame_id": 98,
"result_frame_id": 112,
"inherited": true,
"restarts": 0
},
{
"track_id": 25,
"sku_id": "sku_l09",
"label_box": [
488,
1078,
570,
1248
],
"label_frame_id": 33,
"result_frame_id": 53,
"inherited": true,
"restarts": 1
},
{
"track_id": 26,
"sku_id": "sku_l09",
"label_box": [
198,
755,
258,
972
],
"label_frame_id": 2,
"result_frame_id": 15,
"inherited": false,
"restarts": 0
},
{
"track_id": 27,
"sku_id": "sku_l09",
"label_box": [
484,
1062,
572,
1248
],
"label_frame_id": 89,
"result_frame_id": 97,
"inherited": true,
"restarts": 2
},
{
"track_id": 29,
"sku_id": "sku_l09",
"label_box": [
879,
1055,
972,
1141
],
"label_frame_id": 57,
"result_frame_id": 70,
"inherited": true,
"restarts": 4
},
{
"track_id": 32,
"sku_id": "sku_l10",
"label_box": [
67,
422,
248,
613
],
"label_frame_id": 0,
"result_frame_id": 47,
"inherited": true,
"restarts": 2
},
{
"track_id": 35,
"sku_id": "sku_l09",
"label_box": [
1117,
763,
1179,
973
],
"label_frame_id": 85,
"result_frame_id": 113,
"inherited": true,
"restarts": 1
},
{
"track_id": 58,
"sku_id": "sku_l09",
"label_box": [
1328,
1065,
1379,
1238
],
"label_frame_id": 20,
"result_frame_id": 23,
"inherited": true,
"restarts": 0
},
{
"track_id": 59,
"sku_id": "sku_l09",
"label_box": [
951,
124,
1090,
218
],
"label_frame_id": 0,
"result_frame_id": 104,
"inherited": true,
"restarts": 5
},
{
"track_id": 114,
"sku_id": "sku_l09",
"label_box": [
1191,
1054,
1271,
1245
],
"label_frame_id": 99,
"result_frame_id": 111,
"inherited": false,
"restarts": 3
},
{
"track_id": 127,
"sku_id": "sku_l09",
"label_box": [
243,
755,
298,
972
],
"label_frame_id": 35,
"result_frame_id": 40,
"inherited": true,
"restarts": 1
},
{
"track_id": 135,
"sku_id": "sku_l09",
"label_box": [
521,
118,
608,
330
],
"label_frame_id": 0,
"result_frame_id": 115,
"inherited": true,
"restarts": 4
},
{
"track_id": 136,
"sku_id": "sku_l09",
"label_box": [
1072,
1054,
1147,
1245
],
"label_frame_id": 53,
"result_frame_id": 59,
"inherited": true,
"restarts": 1
},
{
"track_id": 163,
"sku_id": "sku_l09",
"label_box": [
125,
752,
191,
971
],
"label_frame_id": 32,
"result_frame_id": 48,
"inherited": true,
"restarts": 0
},
{
"track_id": 178,
"sku_id": "sku_l10",
"label_box": [
156,
419,
307,
616
],
"label_frame_id": 0,
"result_frame_id": 41,
"inherited": true,
"restarts": 5
},
{
"track_id": 262,
"sku_id": "sku_l09",
"label_box": [
79,
113,
152,
331
],
"label_frame_id": 0,
"result_frame_id": 74,
"inherited": true,
"restarts": 1
},
{
"track_id": 269,
"sku_id": "sku_l09",
"label_box": [
197,
755,
257,
973
],
"label_frame_id": 48,
"result_frame_id": 57,
"inherited": false,
"restarts": 1
},
{
"track_id": 295,
"sku_id": "sku_l09",
"label_box": [
84,
1059,
179,
1148
],
"label_frame_id": 78,
"result_frame_id": 90,
"inherited": true,
"restarts": 1
},
{
"track_id": 302,
"sku_id": "sku_l09",
"label_box": [
941,
410,
1009,
605
],
"label_frame_id": 0,
"result_frame_id": 50,
"inherited": true,
"restarts": 0
},
{
"track_id": 315,
"sku_id": "sku_l09",
"label_box": [
764,
414,
848,
505
],
"label_frame_id": 0,
"result_frame_id": 51,
"inherited": true,
"restarts": 0
},
{
"track_id": 331,
"sku_id": "sku_l09",
"label_box": [
399,
1057,
532,
1255
],
"label_frame_id": 105,
"result_frame_id": 115,
"inherited": true,
"restarts": 9
},
{
"track_id": 345,
"sku_id": "sku_l09",
"label_box": [
763,
415,
854,
499
],
"label_frame_id": 0,
"result_frame_id": 115,
"inherited": true,
"restarts": 3
},
{
"track_id": 353,
"sku_id": "sku_l09",
"label_box": [
1119,
1053,
1180,
1245
],
"label_frame_id": 99,
"result_frame_id": 113,
"inherited": false,
"restarts": 2
},
{
"track_id": 370,
"sku_id": "sku_l09",
"label_box": [
83,
117,
129,
333
],
"label_frame_id": 0,
"result_frame_id": 69,
"inherited": true,
"restarts": 1
},
{
"track_id": 378,
"sku_id": "sku_l09",
"label_box": [
576,
758,
667,
971
],
"label_frame_id": 106,
"result_frame_id": 114,
"inherited": true,
"restarts": 1
},
{
"track_id": 404,
"sku_id": "sku_l09",
"label_box": [
68,
753,
131,
971
],
"label_frame_id": 78,
"result_frame_id": 85,
"inherited": true,
"restarts": 1
},
{
"track_id": 416,
"sku_id": "sku_l09",
"label_box": [
1089,
416,
1147,
604
],
"label_frame_id": 0,
"result_frame_id": 83,
"inherited": true,
"restarts": 1
},
{
"track_id": 499,
"sku_id": "sku_l09",
"label_box": [
443,
767,
510,
968
],
"label_frame_id": 95,
"result_frame_id": 97,
"inherited": true,
"restarts": 0
},
{
"track_id": 507,
"sku_id": "sku_l09",
"label_box": [
149,
757,
208,
971
],
"label_frame_id": 84,
"result_frame_id": 90,
"inherited": true,
"restarts": 1
},
{
"track_id": 552,
"sku_id": "sku_l09",
"label_box": [
757,
413,
849,
507
],
"label_frame_id": 0,
"result_frame_id": 105,
"inherited": true,
"restarts": 1
},
{
"track_id": 582,
"sku_id": "sku_l09",
"label_box": [
701,
413,
842,
505
],
"label_frame_id": 0,
"result_frame_id": 102,
"inherited": true,
"restarts": 1
},
{
"track_id": 613,
"sku_id": "sku_l09",
"label_box": [
477,
767,
537,
969
],
"label_frame_id": 104,
"result_frame_id": 108,
"inherited": true,
"restarts": 0
},
{
"track_id": 673,
"sku_id": "sku_l09",
"label_box": [
1087,
1051,
1143,
1245
],
"label_frame_id": 108,
"result_frame_id": 113,
"inherited": true,
"restarts": 0
}
]
}

View file

@ -1,791 +0,0 @@
{
"source": "add/image6.jpg",
"pace": "send",
"seconds": 12.0,
"tracks": [
{
"track_id": 1,
"sku_id": "sku_l09",
"label_box": [
527,
122,
613,
331
],
"label_frame_id": 0,
"result_frame_id": 104,
"inherited": true,
"restarts": 2
},
{
"track_id": 2,
"sku_id": "sku_l09",
"label_box": [
696,
760,
783,
972
],
"label_frame_id": 70,
"result_frame_id": 81,
"inherited": true,
"restarts": 2
},
{
"track_id": 4,
"sku_id": "sku_l09",
"label_box": [
494,
120,
582,
332
],
"label_frame_id": 0,
"result_frame_id": 108,
"inherited": true,
"restarts": 5
},
{
"track_id": 5,
"sku_id": "sku_l09",
"label_box": [
390,
757,
482,
971
],
"label_frame_id": 106,
"result_frame_id": 112,
"inherited": true,
"restarts": 0
},
{
"track_id": 6,
"sku_id": "sku_l09",
"label_box": [
949,
763,
1049,
973
],
"label_frame_id": 16,
"result_frame_id": 25,
"inherited": true,
"restarts": 0
},
{
"track_id": 8,
"sku_id": "sku_l09",
"label_box": [
399,
754,
497,
971
],
"label_frame_id": 98,
"result_frame_id": 112,
"inherited": true,
"restarts": 0
},
{
"track_id": 9,
"sku_id": "sku_l09",
"label_box": [
441,
1071,
536,
1250
],
"label_frame_id": 74,
"result_frame_id": 89,
"inherited": false,
"restarts": 0
},
{
"track_id": 12,
"sku_id": "sku_l09",
"label_box": [
1194,
1053,
1274,
1244
],
"label_frame_id": 50,
"result_frame_id": 69,
"inherited": true,
"restarts": 1
},
{
"track_id": 13,
"sku_id": "sku_l09",
"label_box": [
1127,
763,
1224,
973
],
"label_frame_id": 97,
"result_frame_id": 112,
"inherited": true,
"restarts": 1
},
{
"track_id": 14,
"sku_id": "sku_l09",
"label_box": [
1277,
1065,
1345,
1239
],
"label_frame_id": 52,
"result_frame_id": 63,
"inherited": true,
"restarts": 2
},
{
"track_id": 15,
"sku_id": "sku_l09",
"label_box": [
805,
764,
976,
972
],
"label_frame_id": 89,
"result_frame_id": 109,
"inherited": false,
"restarts": 0
},
{
"track_id": 16,
"sku_id": "sku_l09",
"label_box": [
583,
1069,
683,
1251
],
"label_frame_id": 99,
"result_frame_id": 112,
"inherited": true,
"restarts": 1
},
{
"track_id": 17,
"sku_id": "sku_l10",
"label_box": [
867,
1147,
958,
1245
],
"label_frame_id": 98,
"result_frame_id": 109,
"inherited": false,
"restarts": 0
},
{
"track_id": 18,
"sku_id": "sku_l09",
"label_box": [
926,
123,
1068,
332
],
"label_frame_id": 0,
"result_frame_id": 102,
"inherited": true,
"restarts": 9
},
{
"track_id": 19,
"sku_id": "sku_l09",
"label_box": [
694,
1057,
794,
1142
],
"label_frame_id": 108,
"result_frame_id": 112,
"inherited": true,
"restarts": 2
},
{
"track_id": 20,
"sku_id": "sku_l10",
"label_box": [
211,
1128,
285,
1260
],
"label_frame_id": 6,
"result_frame_id": 17,
"inherited": false,
"restarts": 2
},
{
"track_id": 21,
"sku_id": "sku_l10",
"label_box": [
848,
1145,
948,
1246
],
"label_frame_id": 98,
"result_frame_id": 112,
"inherited": true,
"restarts": 0
},
{
"track_id": 22,
"sku_id": "sku_l09",
"label_box": [
287,
775,
347,
969
],
"label_frame_id": 2,
"result_frame_id": 18,
"inherited": true,
"restarts": 0
},
{
"track_id": 23,
"sku_id": "sku_l10",
"label_box": [
869,
1144,
952,
1244
],
"label_frame_id": 104,
"result_frame_id": 112,
"inherited": true,
"restarts": 0
},
{
"track_id": 25,
"sku_id": "sku_l09",
"label_box": [
488,
1078,
570,
1248
],
"label_frame_id": 33,
"result_frame_id": 53,
"inherited": true,
"restarts": 1
},
{
"track_id": 26,
"sku_id": "sku_l09",
"label_box": [
198,
755,
258,
972
],
"label_frame_id": 2,
"result_frame_id": 14,
"inherited": false,
"restarts": 0
},
{
"track_id": 27,
"sku_id": "sku_l09",
"label_box": [
522,
1067,
610,
1248
],
"label_frame_id": 87,
"result_frame_id": 92,
"inherited": true,
"restarts": 2
},
{
"track_id": 29,
"sku_id": "sku_l09",
"label_box": [
947,
1055,
1038,
1139
],
"label_frame_id": 58,
"result_frame_id": 73,
"inherited": true,
"restarts": 4
},
{
"track_id": 32,
"sku_id": "sku_l10",
"label_box": [
67,
422,
248,
613
],
"label_frame_id": 0,
"result_frame_id": 47,
"inherited": true,
"restarts": 2
},
{
"track_id": 35,
"sku_id": "sku_l09",
"label_box": [
1057,
764,
1118,
972
],
"label_frame_id": 75,
"result_frame_id": 112,
"inherited": true,
"restarts": 1
},
{
"track_id": 58,
"sku_id": "sku_l09",
"label_box": [
1264,
1065,
1315,
1238
],
"label_frame_id": 14,
"result_frame_id": 23,
"inherited": true,
"restarts": 0
},
{
"track_id": 59,
"sku_id": "sku_l09",
"label_box": [
1092,
126,
1229,
218
],
"label_frame_id": 0,
"result_frame_id": 112,
"inherited": true,
"restarts": 5
},
{
"track_id": 114,
"sku_id": "sku_l09",
"label_box": [
1191,
1054,
1271,
1245
],
"label_frame_id": 99,
"result_frame_id": 108,
"inherited": false,
"restarts": 3
},
{
"track_id": 127,
"sku_id": "sku_l09",
"label_box": [
354,
756,
409,
973
],
"label_frame_id": 26,
"result_frame_id": 40,
"inherited": true,
"restarts": 1
},
{
"track_id": 135,
"sku_id": "sku_l09",
"label_box": [
658,
760,
744,
972
],
"label_frame_id": 92,
"result_frame_id": 104,
"inherited": true,
"restarts": 3
},
{
"track_id": 136,
"sku_id": "sku_l09",
"label_box": [
1076,
1053,
1152,
1245
],
"label_frame_id": 54,
"result_frame_id": 59,
"inherited": true,
"restarts": 1
},
{
"track_id": 163,
"sku_id": "sku_l09",
"label_box": [
111,
754,
177,
970
],
"label_frame_id": 32,
"result_frame_id": 50,
"inherited": true,
"restarts": 0
},
{
"track_id": 178,
"sku_id": "sku_l10",
"label_box": [
156,
419,
307,
616
],
"label_frame_id": 0,
"result_frame_id": 41,
"inherited": true,
"restarts": 5
},
{
"track_id": 262,
"sku_id": "sku_l09",
"label_box": [
449,
763,
522,
968
],
"label_frame_id": 44,
"result_frame_id": 55,
"inherited": false,
"restarts": 0
},
{
"track_id": 269,
"sku_id": "sku_l09",
"label_box": [
113,
758,
172,
972
],
"label_frame_id": 46,
"result_frame_id": 55,
"inherited": true,
"restarts": 1
},
{
"track_id": 295,
"sku_id": "sku_l09",
"label_box": [
84,
1059,
179,
1148
],
"label_frame_id": 78,
"result_frame_id": 90,
"inherited": true,
"restarts": 1
},
{
"track_id": 302,
"sku_id": "sku_l09",
"label_box": [
929,
414,
995,
603
],
"label_frame_id": 0,
"result_frame_id": 51,
"inherited": true,
"restarts": 0
},
{
"track_id": 315,
"sku_id": "sku_l09",
"label_box": [
757,
413,
849,
507
],
"label_frame_id": 0,
"result_frame_id": 67,
"inherited": true,
"restarts": 1
},
{
"track_id": 331,
"sku_id": "sku_l09",
"label_box": [
422,
1057,
555,
1255
],
"label_frame_id": 100,
"result_frame_id": 112,
"inherited": true,
"restarts": 9
},
{
"track_id": 345,
"sku_id": "sku_l09",
"label_box": [
835,
1055,
927,
1140
],
"label_frame_id": 107,
"result_frame_id": 111,
"inherited": true,
"restarts": 2
},
{
"track_id": 353,
"sku_id": "sku_l09",
"label_box": [
1119,
1053,
1180,
1245
],
"label_frame_id": 99,
"result_frame_id": 111,
"inherited": false,
"restarts": 2
},
{
"track_id": 370,
"sku_id": "sku_l09",
"label_box": [
48,
118,
91,
331
],
"label_frame_id": 0,
"result_frame_id": 66,
"inherited": true,
"restarts": 1
},
{
"track_id": 378,
"sku_id": "sku_l09",
"label_box": [
696,
758,
787,
971
],
"label_frame_id": 97,
"result_frame_id": 112,
"inherited": true,
"restarts": 1
},
{
"track_id": 404,
"sku_id": "sku_l09",
"label_box": [
45,
112,
83,
331
],
"label_frame_id": 0,
"result_frame_id": 71,
"inherited": true,
"restarts": 0
},
{
"track_id": 416,
"sku_id": "sku_l09",
"label_box": [
1089,
416,
1147,
604
],
"label_frame_id": 0,
"result_frame_id": 83,
"inherited": true,
"restarts": 1
},
{
"track_id": 476,
"sku_id": "sku_l10",
"label_box": [
63,
519,
138,
614
],
"label_frame_id": 0,
"result_frame_id": 80,
"inherited": true,
"restarts": 0
},
{
"track_id": 499,
"sku_id": "sku_l09",
"label_box": [
79,
113,
152,
331
],
"label_frame_id": 0,
"result_frame_id": 112,
"inherited": true,
"restarts": 1
},
{
"track_id": 507,
"sku_id": "sku_l09",
"label_box": [
128,
114,
187,
331
],
"label_frame_id": 0,
"result_frame_id": 85,
"inherited": true,
"restarts": 1
},
{
"track_id": 536,
"sku_id": "sku_l09",
"label_box": [
619,
1075,
689,
1248
],
"label_frame_id": 100,
"result_frame_id": 108,
"inherited": true,
"restarts": 1
},
{
"track_id": 552,
"sku_id": "sku_l09",
"label_box": [
757,
413,
849,
507
],
"label_frame_id": 0,
"result_frame_id": 105,
"inherited": true,
"restarts": 1
},
{
"track_id": 568,
"sku_id": "sku_l10",
"label_box": [
45,
512,
143,
619
],
"label_frame_id": 0,
"result_frame_id": 90,
"inherited": true,
"restarts": 0
},
{
"track_id": 582,
"sku_id": "sku_l09",
"label_box": [
684,
413,
826,
506
],
"label_frame_id": 0,
"result_frame_id": 102,
"inherited": true,
"restarts": 1
},
{
"track_id": 606,
"sku_id": "sku_l09",
"label_box": [
204,
758,
247,
971
],
"label_frame_id": 102,
"result_frame_id": 112,
"inherited": false,
"restarts": 1
},
{
"track_id": 613,
"sku_id": "sku_l09",
"label_box": [
453,
773,
510,
969
],
"label_frame_id": 100,
"result_frame_id": 111,
"inherited": false,
"restarts": 0
},
{
"track_id": 653,
"sku_id": "sku_l10",
"label_box": [
60,
510,
163,
622
],
"label_frame_id": 0,
"result_frame_id": 104,
"inherited": true,
"restarts": 0
},
{
"track_id": 673,
"sku_id": "sku_l09",
"label_box": [
1087,
1051,
1143,
1245
],
"label_frame_id": 108,
"result_frame_id": 112,
"inherited": true,
"restarts": 0
}
]
}

View file

@ -1,60 +0,0 @@
# External evidence (2026-09-29)
Independent-source media used to stress the identity pipeline. **None of this is
Tier 2.** DECISION.md's Tier 2 is a second labelled store visit; these are
`external_proxy` artifacts, and every manifest here says so.
| Directory | What it holds |
|---|---|
| `other-mayo/` | 8 accepted images for `sku_l04`, `sku_l07`, `sku_l09` from 6 source domains, plus the scored result |
| `l12-reconciliation/` | What `sku_l12` actually is, from the shelf tag and package pixels |
| `kewpie/` | 12 accepted images for six SKUs from 6 source domains, plus the scored result. One is now enrolled, so it is excluded from scoring. |
| `tier3-sources/` | Two reusable pan clips and 11 rejected candidates for a future Tier-3 replay |
## Leak checks
Two, both in `code/eval_external.py`, both run before any number is reported:
- **Against the evaluation photo.** Every image is template-matched against
`add/image6.jpg`. The highest score across all 20 is 0.675 (`sku_l07_1`), and
the 0.95+ band that identifies a crop of the evaluation photo is empty.
- **Against the catalog.** An image that has since been enrolled is refused
rather than scored. `kewpie/sku_l06_1.jpg` is one: it is now the enrolled l06
view, so scoring it would be training accuracy wearing a held-out label.
## Result: 19 scored, 0 false accepts
.venv/bin/python code/eval_external.py --stock stock-lopia-clean \
data/external-eval/kewpie data/external-eval/other-mayo
| Set | Scored | Correct | Abstain | False accepts | Leaked |
|---|---|---|---|---|---|
| `kewpie/` | 11 | 8 | 3 | **0** | 1 |
| `other-mayo/` | 8 | 4 | 4 | **0** | 0 |
| **Total** | **19** | **12** | **7** | **0** | **1** |
Two things this establishes that the image6 shelf cannot:
- **`sku_l07` is answered with no enrolled image.** Both l07 images accept
through the text lane alone: `sku_l07_1` reads ラッキー フヨネーズ, and
`sku_l07_2` reads the rear label's 製造者:丸和油脂株式会社 with 内容量:200g.
The catalog row is 丸和油脂 ラッキーマヨネーズ 200g, so the manufacturer and
the size together name it. This is the text lane's coverage claim - that a
product with no reference image can still be named - holding on data that was
not used to build it.
- **The l04 abstentions are correct behaviour, not a miss.** l04 has no
enrolled image either, and its reads are weak: `sku_l04_2` reads only
コと旨 タ 味 (all rows score 0.0), and `sku_l04_4` reads the rear label with
やまや garbled to まや, scoring 0.379 - below the accept line. Abstaining on
a weak read is the designed outcome.
`sku_l07_2` accepts with a negative local score (-0.55) on text evidence alone.
That is the lane doing its job, but it is worth naming: the accept rests on two
matched tokens (manufacturer + size), not on geometry.
## What is still missing
No external source gives per-SKU identity for the whole catalog, and no
reusable clip combines the target category with shelf-distance text
resolution. The Tier-2 and Tier-3 gates in DECISION.md still need the
operator's own recorded walks.

View file

@ -1,150 +0,0 @@
{
"dataset": "kewpie",
"catalog": "stock-lopia-clean",
"note": "External proxy images, not Tier 2. Scored through the shipped LocalLaneAdapter + DecisionPolicy.",
"rows": [
{
"file": "sku_l01_1.jpg",
"truth": "sku_l01",
"best": "sku_l01",
"confidence": 13.1618,
"status": "success",
"source": "local",
"verdict": "correct"
},
{
"file": "sku_l01_2.jpg",
"truth": "sku_l01",
"best": "sku_l01",
"confidence": 20.986,
"status": "success",
"source": "local",
"verdict": "correct"
},
{
"file": "sku_l02_1.jpg",
"truth": "sku_l02",
"best": "sku_l02",
"confidence": 10.4288,
"status": "success",
"source": "text",
"verdict": "correct"
},
{
"file": "sku_l02_2.jpg",
"truth": "sku_l02",
"best": "sku_l02",
"confidence": 7.542,
"status": "success",
"source": "local",
"verdict": "correct"
},
{
"file": "sku_l03_1.jpg",
"truth": "sku_l03",
"best": "sku_l03",
"confidence": 14.3581,
"status": "success",
"source": "local",
"verdict": "correct"
},
{
"file": "sku_l03_2.jpg",
"truth": "sku_l03",
"best": "sku_l03",
"confidence": 48.9767,
"status": "success",
"source": "local",
"verdict": "correct"
},
{
"file": "sku_l05_1.jpg",
"truth": "sku_l05",
"best": null,
"confidence": 0.0,
"status": "unknown",
"source": null,
"verdict": "abstain"
},
{
"file": "sku_l05_2.jpg",
"truth": "sku_l05",
"best": "sku_l05",
"confidence": -3.9674,
"status": "success",
"source": "text",
"verdict": "correct"
},
{
"file": "sku_l06_1.jpg",
"truth": "sku_l06",
"verdict": "leaked",
"leaked_into": "sku_l06",
"note": "enrolled in the catalog; scoring it would be training accuracy"
},
{
"file": "sku_l06_2.jpg",
"truth": "sku_l06",
"best": "sku_l06",
"confidence": 294.8985,
"status": "success",
"source": "local",
"verdict": "correct"
},
{
"file": "sku_l11_1.jpg",
"truth": "sku_l11",
"best": null,
"confidence": -29.9175,
"status": "unknown",
"source": null,
"verdict": "abstain"
},
{
"file": "sku_l11_2.jpg",
"truth": "sku_l11",
"best": null,
"confidence": 14.1552,
"status": "ambiguous",
"source": null,
"verdict": "abstain"
}
],
"scored": 11,
"leaked": 1,
"by_sku": {
"sku_l01": {
"correct": 2,
"abstain": 0,
"wrong": 0
},
"sku_l02": {
"correct": 2,
"abstain": 0,
"wrong": 0
},
"sku_l03": {
"correct": 2,
"abstain": 0,
"wrong": 0
},
"sku_l05": {
"correct": 1,
"abstain": 1,
"wrong": 0
},
"sku_l06": {
"correct": 1,
"abstain": 0,
"wrong": 0
},
"sku_l11": {
"correct": 0,
"abstain": 2,
"wrong": 0
}
},
"correct": 8,
"abstain": 3,
"wrong": 0
}

View file

@ -1,191 +0,0 @@
{
"dataset_type": "external_proxy",
"overall_note": "Independent-source Internet media for recognition stress testing. This is an external_proxy set: it is not held-out store-visit Tier 2 data and does not satisfy the DECISION.md Tier-2 capture protocol. Every accepted file was visually inspected for the exact Kewpie product and package size. Official Kewpie front packshots already enrolled in stock-lopia-clean were excluded. The two sku_l06 files are current-package retailer images showing 内臓脂肪を減らす キユーピーフィッテ; customer-listing photos showing the superseded 内臓脂肪ケア wrapper were inspected and rejected.",
"license_note": "All accepted files come from commercial retailer or customer marketplace pages without an explicit reuse grant. They are marked restricted-research for internal evaluation only and should not be redistributed.",
"coverage": {
"accepted_images": 12,
"accepted_skus": [
"sku_l01",
"sku_l02",
"sku_l03",
"sku_l05",
"sku_l06",
"sku_l11"
],
"two_images_per_sku": [
"sku_l01",
"sku_l02",
"sku_l03",
"sku_l05",
"sku_l06",
"sku_l11"
],
"two_source_domains_per_sku": [
"sku_l01",
"sku_l02",
"sku_l03",
"sku_l05",
"sku_l06",
"sku_l11"
],
"not_found": []
},
"images": [
{
"file": "sku_l01_1.jpg",
"sku": "sku_l01",
"product": "キユーピー エッグケア 305g",
"source_page": "https://paypayfleamarket.yahoo.co.jp/item/z686464976",
"source_title": "キユーピー エッグケア 卵を使っていないマヨネーズタイプ 305g",
"media_url": "https://auctions.c.yimg.jp/images.auctions.yahoo.co.jp/image/dr000/auc0209/users/ad8016fc0a2ac99427f4bd970e286c7709594046/i-img1200x1200-1789785070735v2rkwx.jpg",
"width": 1200,
"height": 1200,
"view_type": "customer_front",
"license_status": "restricted-research",
"notes": "Customer-taken marketplace photo on a household surface. The front package visibly reads エッグケア and 305g; exact title corroborates the variant. Not the official Kewpie enrollment packshot. Source domain: paypayfleamarket.yahoo.co.jp; media host: auctions.c.yimg.jp."
},
{
"file": "sku_l01_2.jpg",
"sku": "sku_l01",
"product": "キユーピー エッグケア 305g",
"source_page": "https://www.amazon.co.jp/dp/B0BSVY8NNH",
"source_title": "キユーピー エッグケア (卵不使用) マヨネーズ タイプ 305g",
"media_url": "https://m.media-amazon.com/images/I/71lzmm5n5cL._AC_SL1500_.jpg",
"width": 1500,
"height": 1500,
"view_type": "retailer_in_use",
"license_status": "restricted-research",
"notes": "Retailer alternate image with the exact 305g package beside prepared potato salad; package text and size are visible. Not the enrolled plain front packshot. Source/media domain: amazon.co.jp / media-amazon.com."
},
{
"file": "sku_l02_1.jpg",
"sku": "sku_l02",
"product": "キユーピー アマニ油マヨネーズ 200g",
"source_page": "https://paypayfleamarket.yahoo.co.jp/item/z599237268",
"source_title": "キユーピー アマニ油マヨネーズ 200g 2個セット 血圧が高めの方に",
"media_url": "https://auctions.c.yimg.jp/images.auctions.yahoo.co.jp/image/dr000/auc0204/users/f571f9a659b398818c6c54e9862bf90f0e2d78ec/i-img900x1200-1777250065888ef77yp.jpg",
"width": 900,
"height": 1200,
"view_type": "customer_front_pair",
"license_status": "restricted-research",
"notes": "Customer-taken two-pack photo. Both fronts visibly read 血圧ケア アマニ油マヨネーズ and 200g; exact title corroborates size. Source domain: paypayfleamarket.yahoo.co.jp; media host: auctions.c.yimg.jp."
},
{
"file": "sku_l02_2.jpg",
"sku": "sku_l02",
"product": "キユーピー アマニ油マヨネーズ 200g",
"source_page": "https://www.amazon.co.jp/dp/B01F8L6KSQ",
"source_title": "キユーピー アマニ油マヨネーズ 200g [機能性表示食品]",
"media_url": "https://m.media-amazon.com/images/I/71s+50hXsPL._AC_SL1005_.jpg",
"width": 1005,
"height": 937,
"view_type": "back_label",
"license_status": "restricted-research",
"notes": "Retailer alternate rear-label view from the exact 200g listing. The unfolded back panel shows the アマニ油マヨネーズ label and 200g content declaration. Not a front enrollment image. Source/media domain: amazon.co.jp / media-amazon.com."
},
{
"file": "sku_l03_1.jpg",
"sku": "sku_l03",
"product": "キユーピー からしマヨネーズ 200g",
"source_page": "https://paypayfleamarket.yahoo.co.jp/item/z624032610",
"source_title": "キユーピー からしマヨネーズ 200g 4本セット",
"media_url": "https://auctions.c.yimg.jp/images.auctions.yahoo.co.jp/image/dr000/auc0206/users/198e53fdfc1edde3b4933f53fd4f0c02c3346cf5/i-img1200x714-1781002147108o12vd1.jpg",
"width": 1200,
"height": 714,
"view_type": "customer_front_bundle",
"license_status": "restricted-research",
"notes": "Customer-taken four-pack photo with visible からしマヨネーズ fronts; listing title fixes each unit at 200g. Household lighting and grouped packages differ from enrollment. Source domain: paypayfleamarket.yahoo.co.jp; media host: auctions.c.yimg.jp."
},
{
"file": "sku_l03_2.jpg",
"sku": "sku_l03",
"product": "キユーピー からしマヨネーズ 200g",
"source_page": "https://www.amazon.co.jp/dp/B0069K29EK",
"source_title": "キユーピー からしマヨネーズ 200g",
"media_url": "https://m.media-amazon.com/images/I/81ZODR6dv8L._AC_SL1500_.jpg",
"width": 1500,
"height": 1500,
"view_type": "retailer_in_use",
"license_status": "restricted-research",
"notes": "Retailer alternate image showing the exact 200g からしマヨネーズ package beside a prepared dish; the package front and 200g declaration are legible. Not the enrolled plain front packshot. Source/media domain: amazon.co.jp / media-amazon.com."
},
{
"file": "sku_l05_1.jpg",
"sku": "sku_l05",
"product": "キユーピー ゼロ ノンコレステロール 310g",
"source_page": "https://www.amazon.co.jp/dp/B0CBPWDXZJ",
"source_title": "キユーピー ゼロ ノンコレステロール マヨネーズ タイプ 310g",
"media_url": "https://m.media-amazon.com/images/I/81ykD7vVmIL._AC_SL1500_.jpg",
"width": 1305,
"height": 1500,
"view_type": "retailer_in_use_collage",
"license_status": "restricted-research",
"notes": "Retailer alternate usage collage with the exact キユーピーゼロ 310g package among prepared dishes. The composition differs materially from the enrolled front packshot. Source/media domain: amazon.co.jp / media-amazon.com."
},
{
"file": "sku_l05_2.jpg",
"sku": "sku_l05",
"product": "キユーピー ゼロ ノンコレステロール 310g",
"source_page": "https://store.shopping.yahoo.co.jp/eight-cat-store/s-b0cnsbqcly-20260717.html",
"source_title": "キユーピー ゼロ ノンコレステロール マヨネーズ タイプ 310g ×4個",
"media_url": "https://item-shopping.c.yimg.jp/i/n/eight-cat-store_s-b0cnsbqcly-20260717_4_d_20260717075244",
"width": 900,
"height": 900,
"view_type": "retailer_in_use_collage",
"license_status": "restricted-research",
"notes": "Independent retailer-hosted usage collage with the exact キユーピーゼロ 310g package shown among prepared dishes; the package front and 310g declaration are legible. Chosen over the same listing's plain front image so the set stays non-enrollment. Source domain: store.shopping.yahoo.co.jp; media host: item-shopping.c.yimg.jp."
},
{
"file": "sku_l06_1.jpg",
"sku": "sku_l06",
"product": "内臓脂肪を減らす キユーピーフィッテ 210g",
"source_page": "https://www.amazon.co.jp/dp/B08V2676LK",
"source_title": "キユーピー フィッテ 210g",
"media_url": "https://m.media-amazon.com/images/I/81zckoUh+vL._AC_SL1500_.jpg",
"width": 654,
"height": 1500,
"view_type": "retailer_current_package_front",
"license_status": "restricted-research",
"notes": "F-12 current-package evidence. The wrapper visibly says 内臓脂肪を減らす キユーピーフィッテ and 210g; the product page repeats 内臓脂肪を減らす and contains no 内臓脂肪ケア wording. This is not the superseded enrolled package. Source/media domain: amazon.co.jp / media-amazon.com."
},
{
"file": "sku_l06_2.jpg",
"sku": "sku_l06",
"product": "内臓脂肪を減らす キユーピーフィッテ 210g",
"source_page": "https://store.shopping.yahoo.co.jp/ladydrugheartshop/4901577083600.html",
"source_title": "キユーピー フィッテ 210g×5個",
"media_url": "https://item-shopping.c.yimg.jp/i/n/ladydrugheartshop_4901577083600_i_20260902221452",
"width": 500,
"height": 500,
"view_type": "retailer_current_package_front",
"license_status": "restricted-research",
"notes": "Second-domain F-12 corroboration. The current wrapper visibly says 内臓脂肪を減らす and 210g. Three PayPay Flea Market listings were also inspected, but all showed the superseded 内臓脂肪ケア wrapper and were rejected. Source domain: store.shopping.yahoo.co.jp; media host: item-shopping.c.yimg.jp."
},
{
"file": "sku_l11_1.jpg",
"sku": "sku_l11",
"product": "キユーピー ハーフ 400g",
"source_page": "https://paypayfleamarket.yahoo.co.jp/item/z692700538",
"source_title": "キユーピー ハーフ マヨネーズ 400g 2本セット カロリー50%カット",
"media_url": "https://auctions.c.yimg.jp/images.auctions.yahoo.co.jp/image/dr000/auc0209/users/67aace4c38f98ea5a750a86a39665a4c7bc9898d/i-img1200x1200-1790465979767g4gnwe.jpg",
"width": 1200,
"height": 1200,
"view_type": "customer_front_pair",
"license_status": "restricted-research",
"notes": "Customer-taken two-pack photo. Both fronts visibly show キユーピーハーフ; the title identifies each as 400g. Recorded as 400g, not the stock CSV's 450g wording. Source domain: paypayfleamarket.yahoo.co.jp; media host: auctions.c.yimg.jp."
},
{
"file": "sku_l11_2.jpg",
"sku": "sku_l11",
"product": "キユーピー ハーフ 400g",
"source_page": "https://www.amazon.co.jp/dp/B007XT4FIO",
"source_title": "キユーピー ハーフ 400g",
"media_url": "https://m.media-amazon.com/images/I/81u8+jlddyL._AC_SL1500_.jpg",
"width": 1406,
"height": 1500,
"view_type": "retailer_marketing_composite",
"license_status": "restricted-research",
"notes": "Retailer alternate marketing composite with the exact 400g package and product messaging. Not the plain official Kewpie front packshot. Recorded as 400g, matching the pictured package and listing title. Source/media domain: amazon.co.jp / media-amazon.com."
}
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

Some files were not shown because too many files have changed in this diff Show more