chore(eval): reconcile the distractor sets and refresh the sweep numbers

The Wikimedia collector truncated the whole basename at 60 characters, so 13
files lost their extension and cv2 could not read them by name - the distractor
sets were quietly scoring 13 images fewer than they held. It now truncates the
stem and keeps the extension, and the files on disk are renamed to match.

The search terms also pull in .djvu volumes whose page 1 Commons serves as a
JPEG thumbnail. Twelve book scans were in the sets; they contributed 0 false
accepts, and they are not shelves, so the collector filters them and the
manifests no longer list them.

With all 2650 crops readable the sweep is re-measured end to end: 3.09% at the
old lines, 0.30% at the shipped 4.0/4, image6 44 shown / 42 correct / 95.5%.
The accept sweep table and the verdict's far-unknown sentence now carry those
numbers instead of the pre-calibration ones.

Also: the fetched images are gitignored (manifests and results are not), the
l10/l11 shelf-tag crops that back the catalog corrections are committed, and a
stale pre-gate eval.json and a duplicate of eval/labels/image6.json are gone.
This commit is contained in:
kiyo9w 2026-09-29 04:17:54 +09:00
parent 4bdad66bbe
commit 5eb4bdf6c8
19 changed files with 646 additions and 253 deletions

View file

@ -0,0 +1,76 @@
{
"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

@ -0,0 +1,221 @@
{
"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

@ -0,0 +1,27 @@
"""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

@ -0,0 +1,54 @@
"""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.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

View file

@ -0,0 +1,71 @@
"""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.

After

Width:  |  Height:  |  Size: 238 KiB