fix(recognition): calibrate the accept lines against unrelated shelves
Every threshold was fitted on image6, where each crop is a facing of a product the shelf holds, so the set could not price a false accept. Measured on 4580 crops from 64 unrelated Wikimedia shelf photos at the shipped configuration: LOCAL_ACCEPT 2.0 -> 4.0 false accepts 60 (1.31%) -> 3 (0.07%) TEXT_MIN_SHARED 2 -> 4 the two-bigram reads that named sku_l12 via おい/いし On image6 that is 44 shown / 42 correct at full resolution - 95.5% precision, 65.6% coverage - the highest-coverage cell of the joint sweep that passes both G2 (>= 95%) and G3 (<= 1%). At live resolution 56% end-to-end at 97%. The text lane's evidence floor gates its answer only: zeroing the row's score would reorder the ranking and make the margin gate compare a different pair than the one it was measured on. code/sweep_accept_lines.py sweeps both axes jointly through the shipped DecisionPolicy; the earlier table swept one axis while pinning the other, so its baseline rows were not the old line.
This commit is contained in:
parent
4ce41ecb17
commit
5094b5f6ca
12 changed files with 815 additions and 195 deletions
|
|
@ -5,8 +5,11 @@ 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.
|
||||
Measured the way the server measures: a 1280-long-side frame detected at
|
||||
`LiveSettings.conf`. The sources are 4032-4512 px photos, and detecting them at
|
||||
full resolution and at the default `detect_conf` instead measures a
|
||||
configuration the phone never runs, which is how a threshold change reads as a
|
||||
pass on evidence the product does not produce.
|
||||
|
||||
.venv/bin/python code/eval_distractors.py --stock stock-lopia-clean \
|
||||
data/external-shelves/wikimedia-commons
|
||||
|
|
@ -28,7 +31,13 @@ if current_dir not in sys.path:
|
|||
|
||||
import cv2 # noqa: E402
|
||||
|
||||
from eval_identity import LOCAL_ACCEPT, LocalLaneAdapter # noqa: E402
|
||||
from eval_identity import ( # noqa: E402
|
||||
LIVE_LONG_SIDE,
|
||||
LOCAL_ACCEPT,
|
||||
LocalLaneAdapter,
|
||||
at_live_resolution,
|
||||
)
|
||||
from identity_lanes import TEXT_MIN_SHARED # noqa: E402
|
||||
|
||||
|
||||
def main(argv: List[str] | None = None) -> int:
|
||||
|
|
@ -40,9 +49,11 @@ def main(argv: List[str] | None = None) -> int:
|
|||
args = parser.parse_args(argv)
|
||||
|
||||
from engine import build_default_engine, EngineSettings
|
||||
from live import LiveSettings
|
||||
|
||||
engine = build_default_engine(EngineSettings(stock_dir=args.stock))
|
||||
lane = LocalLaneAdapter(args.stock)
|
||||
conf = LiveSettings().conf
|
||||
for directory in args.directories:
|
||||
manifest_path = os.path.join(directory, "manifest.json")
|
||||
try:
|
||||
|
|
@ -65,14 +76,15 @@ def main(argv: List[str] | None = None) -> int:
|
|||
if image is None:
|
||||
continue
|
||||
started = time.perf_counter()
|
||||
detections = engine.detect(image)
|
||||
frame = at_live_resolution(image)
|
||||
detections = engine.detect(frame, conf=conf)
|
||||
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]
|
||||
crop = frame[max(0, y1):y2, max(0, x1):x2]
|
||||
if crop.size == 0:
|
||||
continue
|
||||
decision = lane.decide(crop, lane.rank(crop), accept=LOCAL_ACCEPT,
|
||||
|
|
@ -93,6 +105,14 @@ def main(argv: List[str] | None = None) -> int:
|
|||
"dataset": os.path.basename(directory.rstrip("/")),
|
||||
"catalog": args.stock,
|
||||
"note": "Unrelated shelves: every accept is a false accept by construction.",
|
||||
# The configuration the numbers above were measured under, so the
|
||||
# stored evidence is checkable against the constants it claims.
|
||||
"config": {
|
||||
"frame_long_side": LIVE_LONG_SIDE,
|
||||
"detect_conf": conf,
|
||||
"local_accept": LOCAL_ACCEPT,
|
||||
"text_min_shared": TEXT_MIN_SHARED,
|
||||
},
|
||||
"images": len(rows),
|
||||
"detections": detections,
|
||||
"false_accepts": accepts,
|
||||
|
|
|
|||
|
|
@ -418,12 +418,17 @@ class LocalLaneAdapter(Lane):
|
|||
|
||||
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.
|
||||
# running the same crops without it. `--no-text` therefore drops the
|
||||
# lane whole: nothing in the decision reads it, and an ablation that
|
||||
# still had to build an OCR engine could not be run on a machine
|
||||
# without one.
|
||||
self.use_text = use_text
|
||||
self.text_lane = TextLane.from_catalog(
|
||||
self.text_lane = (
|
||||
TextLane.from_catalog(
|
||||
_catalog_rows(stock_dir), stock_dir, ocr if ocr is not None else _ocr_engine()
|
||||
)
|
||||
if use_text else None
|
||||
)
|
||||
|
||||
def rank(self, crop: Any) -> List[Tuple[str, float]]:
|
||||
return self.lane.rank(crop)
|
||||
|
|
@ -433,9 +438,10 @@ class LocalLaneAdapter(Lane):
|
|||
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)
|
||||
# E-3's other arm: the same policy, over the same ranking, with no
|
||||
# text evidence. `dict(ranked)` would not do - it drops the lane's
|
||||
# order, and the value is not the order (see `LocalLane.rank`).
|
||||
return DecisionPolicy(local_accept=accept).decide(ranked)
|
||||
read = self.text_lane.read(crop)
|
||||
text_rank = sorted(
|
||||
self.text_lane.scores_from_text(read).items(),
|
||||
|
|
@ -523,8 +529,6 @@ def score_lane(
|
|||
the SKU is on the shelf but has no reference, so an accept there is wrong
|
||||
and is the failure the enrollment protocol exists to prevent.
|
||||
"""
|
||||
from identity import decide_identity
|
||||
|
||||
in_catalog = [f for f in facings if f["label"] != UNKNOWN]
|
||||
out_catalog = [f for f in facings if f["label"] == UNKNOWN]
|
||||
enrolled_count = sum(1 for f in in_catalog if f["label"] in enrolled_skus)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ store was enrolled; scoring them is training-set accuracy.
|
|||
|
||||
python code/eval_live.py RECORDING_DIR [--stock-dir DIR] [--labels PATH]
|
||||
[--json PATH] [--iou 0.5]
|
||||
[--replay] [--identity matcher|local|gallery] [--device cpu|mps|cuda]
|
||||
[--replay] [--identity matcher|local|gallery|van] [--device cpu|mps|cuda]
|
||||
|
||||
Prints a human count table and writes the same report as JSON (default
|
||||
<recording>/eval_live.json). Missing input exits 2 with {"error": ...} on
|
||||
|
|
@ -66,7 +66,7 @@ def content_sha256(payload: bytes) -> str:
|
|||
# Every backend the server can be started with, so a recording made by the
|
||||
# shipped `run-demo-server.sh` (--identity local) can be replayed. `local` is
|
||||
# the default; `matcher` is Van's scorer kept as the E-1 baseline lane.
|
||||
IDENTITY_BACKENDS = ("matcher", "local", "gallery")
|
||||
IDENTITY_BACKENDS = ("matcher", "local", "gallery", "van")
|
||||
|
||||
|
||||
def _iou(a: Sequence[float], b: Sequence[float]) -> float:
|
||||
|
|
|
|||
|
|
@ -45,28 +45,38 @@ from rebuild_catalog import bigrams, normalize_text, signature_bigrams, sku_of
|
|||
# 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.
|
||||
# a facing of a product the shelf actually holds, so that set could not price
|
||||
# it. Re-measured with `code/sweep_accept_lines.py` on 4580 crops from 64
|
||||
# unrelated Wikimedia shelf photos - 1280-long-side frames at
|
||||
# `LiveSettings.conf`, the configuration the phone runs - against
|
||||
# `TEXT_MIN_SHARED = 4`: 2.0 accepts 60 of them (1.31%) and 4.0 accepts 3
|
||||
# (0.07%), against G3's 1%. On image6 at full resolution 4.0 shows 44 labels of
|
||||
# which 42 are right - 95.5% precision, and 65.6% of the 64 facings that hold a
|
||||
# catalog row - and it is the highest-coverage cell in the sweep that passes
|
||||
# both G2 and G3.
|
||||
LOCAL_ACCEPT = 4.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.
|
||||
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.
|
||||
# How many of the read's bigrams the winning row must actually contain before
|
||||
# that read may name a product. A floor on the amount of shared evidence, not
|
||||
# on the ratio: a two-pair read the row happens to contain scores 1.0, and
|
||||
# "サソース" named sku_l08 that way.
|
||||
#
|
||||
# It gates the text lane's *answer* and nothing else. Zeroing the row's score
|
||||
# would be a different policy under the same name: it reorders the ranking, so
|
||||
# a zeroed top row promotes a runner-up and the margin gate ends up comparing a
|
||||
# different pair than the one it was measured on. It would also gate the veto,
|
||||
# which is backwards - the veto only ever refuses a local accept, so a floor
|
||||
# under it would let a two-bigram read rescue a match the local lane earned.
|
||||
# `code/sweep_accept_lines.py` measures the line by calling the policy below
|
||||
# with this value, so the sweep and the shipped lane cannot disagree about what
|
||||
# it means.
|
||||
#
|
||||
# The ratio stays the ranking's own gate, so a long read of shared brand copy
|
||||
# still scores low and is still refused by the margin.
|
||||
TEXT_MIN_SHARED = 4
|
||||
# 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
|
||||
|
|
@ -348,8 +358,15 @@ def text_similarity(
|
|||
the most real text to match were punished hardest. Bounded this way the
|
||||
same reads score 0.86 and 1.0 against their true rows, and the garbage
|
||||
read ties across three rows and is refused by the margin.
|
||||
|
||||
`vocabulary` defaults to the weight table's own keys, which are by
|
||||
construction the bigrams some signature produces. A second copy of that set
|
||||
on the lane is a second thing to keep in step, and it is the drift the
|
||||
module already paid for once with the folding rules.
|
||||
"""
|
||||
read_grams = bigrams(read)
|
||||
if vocabulary is None and weights is not None:
|
||||
vocabulary = set(weights)
|
||||
if vocabulary is not None:
|
||||
read_grams &= vocabulary
|
||||
if not read_grams:
|
||||
|
|
@ -404,12 +421,6 @@ class TextLane:
|
|||
# Bigram weights over the catalog's own names, so the lane can tell
|
||||
# copy every sibling prints from the words that name one product.
|
||||
self.weights = bigram_weights(list(self.signatures.values()))
|
||||
# Every pair any name can produce. A read's pairs outside this set are
|
||||
# OCR noise or word-boundary artifacts, and counting them in the
|
||||
# denominator charges a row for text it could never have explained.
|
||||
self.vocabulary: Set[str] = set()
|
||||
for text in self.signatures.values():
|
||||
self.vocabulary |= signature_bigrams(text)
|
||||
self.ocr_engine = ocr_engine
|
||||
# The engine's OCR lock. Vision tolerates concurrency but the CPU does
|
||||
# not, and this lane runs one OCR per crop on the classify pool, so it
|
||||
|
|
@ -472,7 +483,7 @@ class TextLane:
|
|||
if len(normalize_text(read)) < 4:
|
||||
return {}
|
||||
return {
|
||||
sku: text_similarity(read, signature, self.weights, self.vocabulary)
|
||||
sku: text_similarity(read, signature, self.weights)
|
||||
for sku, signature in self.signatures.items()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -306,6 +306,9 @@ class LiveSession:
|
|||
self._frames_dropped = 0
|
||||
self._process_times: "collections.deque[float]" = collections.deque(maxlen=STATS_WINDOW)
|
||||
self._classify_times: "collections.deque[float]" = collections.deque(maxlen=STATS_WINDOW)
|
||||
# A lane that raised is an operational failure, not "nothing matched".
|
||||
# Counted so a broken run is never read as a coverage change.
|
||||
self._classify_failures = 0
|
||||
self._slot_history: "collections.deque[List[Dict[str, Any]]]" = collections.deque(
|
||||
maxlen=EMPTY_SLOT_HISTORY
|
||||
)
|
||||
|
|
@ -1198,7 +1201,14 @@ class LiveSession:
|
|||
) -> None:
|
||||
try:
|
||||
scores, text_rank, text_read = future.result()
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
# A lane that raised is an operational failure, not a crop that
|
||||
# matched nothing. Swallowing it made a broken run (tracks, no
|
||||
# labels, clean log) indistinguishable from a coverage change, so
|
||||
# it is counted and logged before the empty answer is used.
|
||||
with self._lock:
|
||||
self._classify_failures += 1
|
||||
LOGGER.exception("classification failed for track %s: %s", track_id, exc)
|
||||
scores, text_rank, text_read = {}, [], ""
|
||||
finished_at = time.perf_counter()
|
||||
# Resolved before the session lock is taken. `identity_text_lane` builds
|
||||
|
|
@ -1457,6 +1467,7 @@ class LiveSession:
|
|||
"frames_dropped": self._frames_dropped,
|
||||
"server_fps": fps,
|
||||
"classify_pending": sum(track.in_flight for track in self._tracks.values()),
|
||||
"classify_failures": self._classify_failures,
|
||||
"classify_ms_p50": classify_p50,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,13 @@ Reported per run: correct / in-catalog facings, precision, unknown accepts, and
|
|||
tracks that matched no labelled facing. A track whose box overlaps a facing the
|
||||
catalog does not enrol is an unenrolled false accept - the dangerous kind, and
|
||||
the one the evaluator's own precision denominator used to hide.
|
||||
|
||||
G1 has two halves - new covers at least as much as Van, and the lower bound of
|
||||
the paired-bootstrap difference clears -2 pp - and the second half needs the
|
||||
per-facing pairing, so every row carries the `facing` index it matched. Two
|
||||
reports plus the labels then answer the gate on the same facings:
|
||||
|
||||
python code/score_stream.py NEW.json --van VAN.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -22,6 +29,7 @@ from __future__ import annotations
|
|||
import argparse
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
|
|
@ -33,6 +41,14 @@ if current_dir not in sys.path:
|
|||
# so the denominator keeps only facings at least this far inside the window.
|
||||
VISIBLE_MIN = 0.5
|
||||
|
||||
# G1's second half: the lower bound of the paired difference must clear -2 pp.
|
||||
G1_BOUND_MIN = -0.02
|
||||
|
||||
# Resampling draws for the paired bootstrap, and the seed that makes the bound
|
||||
# a number this repo can print twice.
|
||||
BOOTSTRAP_DRAWS = 10000
|
||||
BOOTSTRAP_SEED = 0
|
||||
|
||||
|
||||
def iou(a: Sequence[int], b: Sequence[int]) -> float:
|
||||
ax1, ay1, ax2, ay2 = a
|
||||
|
|
@ -72,6 +88,22 @@ def visible_fraction(box: Sequence[int], window: Sequence[int]) -> float:
|
|||
return (inter_w * inter_h) / area if area > 0 else 0.0
|
||||
|
||||
|
||||
def reachable(facings: Sequence[Dict[str, Any]],
|
||||
windows: Optional[Sequence[Sequence[int]]]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Facings a run's windows showed well enough for the run to have named."""
|
||||
if not windows:
|
||||
return list(facings)
|
||||
return [f for f in facings
|
||||
if max(visible_fraction(f["box"], w) for w in windows) >= VISIBLE_MIN]
|
||||
|
||||
|
||||
def correct_facings(rows: Sequence[Dict[str, Any]]) -> set:
|
||||
"""Facing indices at least one track got right - one hit each, not one per track."""
|
||||
return {row["facing"] for row in rows
|
||||
if row["outcome"] == "correct" and row["facing"] is not None}
|
||||
|
||||
|
||||
def score(tracks: Sequence[Dict[str, Any]], facings: Sequence[Dict[str, Any]],
|
||||
stock_dir: str, iou_min: float,
|
||||
windows: Optional[Sequence[Sequence[int]]] = None
|
||||
|
|
@ -90,11 +122,7 @@ def score(tracks: Sequence[Dict[str, Any]], facings: Sequence[Dict[str, Any]],
|
|||
text lane names SKUs the image lane has no view for, which is the point of
|
||||
it, and scoring that as a false accept punishes the lane for working.
|
||||
"""
|
||||
if windows:
|
||||
facings = [
|
||||
f for f in facings
|
||||
if max(visible_fraction(f["box"], w) for w in windows) >= VISIBLE_MIN
|
||||
]
|
||||
facings = reachable(facings, windows)
|
||||
in_catalog = [f for f in facings if f["label"] != "unknown"]
|
||||
enrolled = enrolled_skus(stock_dir)
|
||||
|
||||
|
|
@ -122,7 +150,8 @@ def score(tracks: Sequence[Dict[str, Any]], facings: Sequence[Dict[str, Any]],
|
|||
if best is None or best_iou < iou_min:
|
||||
unmatched += 1
|
||||
rows.append({"track_id": track["track_id"], "predicted": track["sku_id"],
|
||||
"truth": None, "iou": round(best_iou, 3), "outcome": "unmatched"})
|
||||
"facing": None, "truth": None, "iou": round(best_iou, 3),
|
||||
"outcome": "unmatched"})
|
||||
continue
|
||||
truth = best["label"]
|
||||
predicted = track["sku_id"]
|
||||
|
|
@ -140,7 +169,8 @@ def score(tracks: Sequence[Dict[str, Any]], facings: Sequence[Dict[str, Any]],
|
|||
wrong += 1
|
||||
outcome = "wrong"
|
||||
rows.append({"track_id": track["track_id"], "predicted": predicted,
|
||||
"truth": truth, "iou": round(best_iou, 3), "outcome": outcome})
|
||||
"facing": best["index"], "truth": truth,
|
||||
"iou": round(best_iou, 3), "outcome": outcome})
|
||||
|
||||
shown = correct + wrong + unenrolled_false + unknown_false
|
||||
return {
|
||||
|
|
@ -155,10 +185,81 @@ def score(tracks: Sequence[Dict[str, Any]], facings: Sequence[Dict[str, Any]],
|
|||
"shown": shown,
|
||||
"coverage": round(len(named) / len(in_catalog), 4) if in_catalog else 0.0,
|
||||
"precision": round(correct / shown, 4) if shown else 0.0,
|
||||
# The window set travels with the report: a paired comparison has to
|
||||
# know what each run was shown, not just what it named.
|
||||
"windows": [list(w) for w in windows] if windows else None,
|
||||
"rows": rows,
|
||||
}
|
||||
|
||||
|
||||
def paired_bootstrap(van: Dict[str, Any], new: Dict[str, Any],
|
||||
facings: Sequence[Dict[str, Any]],
|
||||
draws: int = BOOTSTRAP_DRAWS,
|
||||
seed: int = BOOTSTRAP_SEED) -> Dict[str, Any]:
|
||||
"""G1's second half: new minus Van, per facing, with the 95 % lower bound.
|
||||
|
||||
Both reports are graded on one facing set - the in-catalog facings either
|
||||
run's windows showed - so a facing one run panned past cannot flatter the
|
||||
other. Resampling draws facings with replacement rather than comparing two
|
||||
independent rates, because the two runs saw the same photo: pairing removes
|
||||
the facing-to-facing variance that otherwise swamps a 27-facing sample.
|
||||
|
||||
Percentile method on the sorted draw differences; the seed is fixed so the
|
||||
number in a report is one this repo reproduces.
|
||||
"""
|
||||
windows = list(van.get("windows") or []) + list(new.get("windows") or [])
|
||||
shared = sorted(f["index"] for f in reachable(facings, windows)
|
||||
if f["label"] != "unknown")
|
||||
if not shared:
|
||||
return {"facings": 0, "difference": 0.0, "lower_95": 0.0, "upper_95": 0.0,
|
||||
"van": 0, "new": 0, "draws": draws, "seed": seed}
|
||||
|
||||
van_hits = correct_facings(van["rows"])
|
||||
new_hits = correct_facings(new["rows"])
|
||||
van_vector = [1 if i in van_hits else 0 for i in shared]
|
||||
new_vector = [1 if i in new_hits else 0 for i in shared]
|
||||
n = len(shared)
|
||||
|
||||
rng = random.Random(seed)
|
||||
spread = []
|
||||
for _ in range(draws):
|
||||
van_sum = new_sum = 0
|
||||
for _ in range(n):
|
||||
# The same draw index into both vectors: that is the pairing.
|
||||
drawn = rng.randrange(n)
|
||||
van_sum += van_vector[drawn]
|
||||
new_sum += new_vector[drawn]
|
||||
spread.append((new_sum - van_sum) / n)
|
||||
spread.sort()
|
||||
tail = max(1, int(0.025 * draws))
|
||||
return {
|
||||
"facings": n,
|
||||
"van": sum(van_vector),
|
||||
"new": sum(new_vector),
|
||||
"van_rate": round(sum(van_vector) / n, 4),
|
||||
"new_rate": round(sum(new_vector) / n, 4),
|
||||
"difference": round((sum(new_vector) - sum(van_vector)) / n, 4),
|
||||
"lower_95": round(spread[tail - 1], 4),
|
||||
"upper_95": round(spread[min(draws - 1, int(0.975 * draws))], 4),
|
||||
"draws": draws,
|
||||
"seed": seed,
|
||||
}
|
||||
|
||||
|
||||
def load_run(path: str, window: Optional[str] = None
|
||||
) -> Tuple[Dict[str, Any], Optional[List[Tuple[int, ...]]]]:
|
||||
"""A stream_client --tracks dump and the source-image windows it showed."""
|
||||
with open(path) as handle:
|
||||
payload = json.load(handle)
|
||||
if window:
|
||||
windows = [tuple(int(part) for part in window.split(","))]
|
||||
elif payload.get("windows"):
|
||||
windows = [tuple(w) for w in payload["windows"]]
|
||||
else:
|
||||
windows = None
|
||||
return payload, windows
|
||||
|
||||
|
||||
def main(argv: Optional[Sequence[str]] = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Score streamed labels against ground truth")
|
||||
parser.add_argument("tracks", help="JSON written by stream_client.py --tracks")
|
||||
|
|
@ -169,22 +270,20 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
|||
parser.add_argument("--window", default=None,
|
||||
help="x0,y0,x1,y1 the client showed; the denominator is "
|
||||
"restricted to facings at least half inside it")
|
||||
parser.add_argument("--van", default=None,
|
||||
help="Van baseline track dump on the same clip; scores G1's "
|
||||
"paired-bootstrap half against it")
|
||||
parser.add_argument("--draws", type=int, default=BOOTSTRAP_DRAWS)
|
||||
parser.add_argument("--seed", type=int, default=BOOTSTRAP_SEED)
|
||||
parser.add_argument("--verbose", action="store_true", help="print every scored track")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
with open(args.tracks) as handle:
|
||||
payload = json.load(handle)
|
||||
windows = None
|
||||
if args.window:
|
||||
windows = [tuple(int(part) for part in args.window.split(","))]
|
||||
elif payload.get("windows"):
|
||||
windows = [tuple(w) for w in payload["windows"]]
|
||||
report = score(payload["tracks"], load_facings(args.labels), args.stock_dir,
|
||||
args.iou, windows)
|
||||
facings = load_facings(args.labels)
|
||||
payload, windows = load_run(args.tracks, args.window)
|
||||
report = score(payload["tracks"], facings, args.stock_dir, args.iou, windows)
|
||||
report["source"] = payload.get("source")
|
||||
report["pace"] = payload.get("pace")
|
||||
report["seconds"] = payload.get("seconds")
|
||||
report["windows"] = [list(w) for w in windows] if windows else None
|
||||
|
||||
print(f"{os.path.basename(args.tracks)} pace={report['pace']} "
|
||||
f"{report['seconds']}s")
|
||||
|
|
@ -197,7 +296,27 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
|||
if args.verbose:
|
||||
for row in report["rows"]:
|
||||
print(f" t{row['track_id']:4d} {row['outcome']:24s} "
|
||||
f"pred={row['predicted']} truth={row['truth']} iou={row['iou']}")
|
||||
f"facing={row['facing']} pred={row['predicted']} "
|
||||
f"truth={row['truth']} iou={row['iou']}")
|
||||
|
||||
if args.van:
|
||||
van_payload, van_windows = load_run(args.van, args.window)
|
||||
van_report = score(van_payload["tracks"], facings, args.stock_dir,
|
||||
args.iou, van_windows)
|
||||
paired = paired_bootstrap(van_report, report, facings, args.draws, args.seed)
|
||||
report["van"] = {k: van_report[k] for k in
|
||||
("in_catalog_facings", "facings_named", "coverage",
|
||||
"correct", "wrong", "precision", "shown")}
|
||||
report["g1_paired"] = paired
|
||||
print(f" G1 vs {os.path.basename(args.van)}: new {paired['new']}/"
|
||||
f"{paired['facings']} ({paired['new_rate']:.0%}) - van {paired['van']}/"
|
||||
f"{paired['facings']} ({paired['van_rate']:.0%}) = "
|
||||
f"{paired['difference'] * 100:+.1f} pp")
|
||||
print(f" paired bootstrap {paired['draws']} draws seed {paired['seed']}: "
|
||||
f"95% bounds {paired['lower_95'] * 100:+.1f} pp to "
|
||||
f"{paired['upper_95'] * 100:+.1f} pp; lower bound clears "
|
||||
f"{G1_BOUND_MIN * 100:+.0f} pp: "
|
||||
f"{'yes' if paired['lower_95'] >= G1_BOUND_MIN else 'NO'}")
|
||||
|
||||
if args.json_path:
|
||||
with open(args.json_path, "w") as handle:
|
||||
|
|
|
|||
225
recognization/code/sweep_accept_lines.py
Normal file
225
recognization/code/sweep_accept_lines.py
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
"""Joint sweep of the two open-set gates: LOCAL_ACCEPT x TEXT_MIN_SHARED.
|
||||
|
||||
Both lines were fitted on image6, where every crop is a facing of a product the
|
||||
shelf actually holds. That set can only measure false accepts on the products it
|
||||
happens to contain, so it cannot price a line on its own - the far-unknown rate
|
||||
comes from `data/external-shelves/*`: Wikimedia Commons shelf photos that share
|
||||
nothing with the catalog, where every accept is a false accept by construction.
|
||||
|
||||
Each cell is produced by calling the shipped `DecisionPolicy` with that cell's
|
||||
values and the catalog signatures, never by re-implementing the gate in the
|
||||
sweep: a sweep that scores its own copy of the policy reports on a machine the
|
||||
server does not run. `TEXT_MIN_SHARED` is a floor on the evidence behind the
|
||||
text lane's *answer*. It is deliberately not a floor on the score - zeroing a
|
||||
row reorders the ranking, so the margin gate would compare a different pair -
|
||||
and deliberately not a floor on the veto, which only ever refuses. The constant
|
||||
in `identity_lanes.py` says the same thing next to the code that does it.
|
||||
|
||||
The distractor side is measured the way the phone measures it - a 1280-long-side
|
||||
frame at `LiveSettings.conf`, the path `code/eval_distractors.py` runs. The
|
||||
shelf is scored at both resolutions: the full-resolution column is the one the
|
||||
published table quotes, the live column is what the phone sees.
|
||||
|
||||
RootSIFT and OCR dominate the runtime and neither depends on the gates, so the
|
||||
per-crop evidence is cached. Without `--cache` the sweep rebuilds it every run
|
||||
and prints the same numbers, only slower.
|
||||
|
||||
.venv/bin/python code/sweep_accept_lines.py \\
|
||||
--cache data/evaluations/2026-09-29-session/scratch/accept_line_cache.pkl
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import os
|
||||
import pickle
|
||||
import sys
|
||||
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:
|
||||
sys.path.insert(0, current_dir)
|
||||
|
||||
import cv2 # noqa: E402
|
||||
|
||||
from eval_identity import ( # noqa: E402
|
||||
EXCLUDE,
|
||||
LIVE_LONG_SIDE,
|
||||
UNKNOWN,
|
||||
LocalLaneAdapter,
|
||||
at_live_resolution,
|
||||
crop_of,
|
||||
load_labels,
|
||||
)
|
||||
from identity_lanes import LOCAL_ACCEPT, TEXT_MIN_SHARED, DecisionPolicy # noqa: E402
|
||||
|
||||
SHELF_LABELS = "eval/labels/image6.json"
|
||||
SHELF_IMAGE = "add/image6.jpg"
|
||||
DISTRACTOR_DIRS = (
|
||||
"data/external-shelves/wikimedia-commons",
|
||||
"data/external-shelves/wikimedia-commons-2",
|
||||
)
|
||||
LOCAL_ACCEPTS = (2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0)
|
||||
TEXT_MIN_SHAREDS = (2, 3, 4, 5, 6)
|
||||
CACHE_VERSION = 3
|
||||
|
||||
# One crop's evidence: the local lane's ranking and the text lane's raw read.
|
||||
Evidence = Tuple[Any, str]
|
||||
# One facing: its ground-truth label plus that evidence.
|
||||
Facing = Tuple[str, Any, str]
|
||||
|
||||
|
||||
def _evidence(lane: LocalLaneAdapter, frame: Any, box: Sequence[int]) -> Optional[Evidence]:
|
||||
"""Rank and read one crop, or None when the box is off the frame."""
|
||||
crop = crop_of(frame, box)
|
||||
if crop.size == 0:
|
||||
return None
|
||||
return lane.rank(crop), lane.text_lane.read(crop)
|
||||
|
||||
|
||||
def build_cache(stock_dir: str) -> Dict[str, Any]:
|
||||
"""Every crop's evidence, gathered once and swept many times."""
|
||||
from engine import EngineSettings, build_default_engine
|
||||
from live import LiveSettings
|
||||
|
||||
lane = LocalLaneAdapter(stock_dir)
|
||||
image = cv2.imread(SHELF_IMAGE)
|
||||
live = at_live_resolution(image)
|
||||
factor = live.shape[1] / float(image.shape[1])
|
||||
|
||||
shelf_full: List[Facing] = []
|
||||
shelf_live: List[Facing] = []
|
||||
for facing in load_labels(SHELF_LABELS)["facings"]:
|
||||
label = facing.get("label")
|
||||
if label in (None, EXCLUDE):
|
||||
continue
|
||||
full = _evidence(lane, image, facing["box"])
|
||||
if full is None:
|
||||
continue
|
||||
shelf_full.append((label, full[0], full[1]))
|
||||
# A facing the live frame cuts to nothing is dropped rather than
|
||||
# scored at full resolution: the two columns have to be the same crops
|
||||
# at two sizes, or the resolution is not the only variable.
|
||||
small = _evidence(lane, live, [int(round(v * factor)) for v in facing["box"]])
|
||||
if small is not None:
|
||||
shelf_live.append((label, small[0], small[1]))
|
||||
|
||||
engine = build_default_engine(EngineSettings(stock_dir=stock_dir))
|
||||
conf = LiveSettings().conf
|
||||
distractors: List[Evidence] = []
|
||||
photos = 0
|
||||
for directory in DISTRACTOR_DIRS:
|
||||
paths = sorted(
|
||||
path for path in glob.glob(os.path.join(directory, "*"))
|
||||
if os.path.splitext(path)[1].lower() in (".jpg", ".jpeg", ".png")
|
||||
)
|
||||
for path in paths:
|
||||
shelf_photo = cv2.imread(path)
|
||||
if shelf_photo is None:
|
||||
continue
|
||||
photos += 1
|
||||
frame = at_live_resolution(shelf_photo)
|
||||
for detection in engine.detect(frame, conf=conf):
|
||||
box = detection.get("box") or detection.get("bbox")
|
||||
if not box:
|
||||
continue
|
||||
item = _evidence(lane, frame, [int(v) for v in box])
|
||||
if item is not None:
|
||||
distractors.append(item)
|
||||
return {
|
||||
"version": CACHE_VERSION,
|
||||
"conf": conf,
|
||||
"photos": photos,
|
||||
"shelf_full": shelf_full,
|
||||
"shelf_live": shelf_live,
|
||||
"distractors": distractors,
|
||||
}
|
||||
|
||||
|
||||
def _score(lane: LocalLaneAdapter, policy: DecisionPolicy, facings: Sequence[Facing]) -> Tuple[int, int]:
|
||||
"""Shown and correct labels for one shelf column under one policy."""
|
||||
shown = correct = 0
|
||||
for label, ranked, read in facings:
|
||||
text = sorted(
|
||||
lane.text_lane.scores_from_text(read).items(), key=lambda kv: kv[1], reverse=True
|
||||
)
|
||||
answer = policy.decide(ranked, text, text_read=read)["best_sku"]
|
||||
if answer:
|
||||
shown += 1
|
||||
correct += answer == label
|
||||
return shown, correct
|
||||
|
||||
|
||||
def _false_accepts(lane: LocalLaneAdapter, policy: DecisionPolicy,
|
||||
distractors: Sequence[Evidence]) -> int:
|
||||
"""Every accept on an unrelated shelf is a false accept by construction."""
|
||||
accepts = 0
|
||||
for ranked, read in distractors:
|
||||
text = sorted(
|
||||
lane.text_lane.scores_from_text(read).items(), key=lambda kv: kv[1], reverse=True
|
||||
)
|
||||
if policy.decide(ranked, text, text_read=read)["best_sku"]:
|
||||
accepts += 1
|
||||
return accepts
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--stock", default="stock-lopia-clean")
|
||||
parser.add_argument("--cache", default="", help="build once, reuse on later runs")
|
||||
parser.add_argument("--rebuild", action="store_true", help="ignore an existing cache")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
cache: Optional[Dict[str, Any]] = None
|
||||
if args.cache and os.path.exists(args.cache) and not args.rebuild:
|
||||
with open(args.cache, "rb") as handle:
|
||||
cache = pickle.load(handle)
|
||||
if cache.get("version") != CACHE_VERSION:
|
||||
cache = None
|
||||
if cache is None:
|
||||
cache = build_cache(args.stock)
|
||||
if args.cache:
|
||||
os.makedirs(os.path.dirname(os.path.abspath(args.cache)), exist_ok=True)
|
||||
with open(args.cache, "wb") as handle:
|
||||
pickle.dump(cache, handle)
|
||||
|
||||
lane = LocalLaneAdapter(args.stock)
|
||||
shelf_full: List[Facing] = cache["shelf_full"]
|
||||
shelf_live: List[Facing] = cache["shelf_live"]
|
||||
distractors: List[Evidence] = cache["distractors"]
|
||||
known = sum(1 for label, _, _ in shelf_full if label != UNKNOWN)
|
||||
|
||||
print(f"catalog {args.stock}, {LIVE_LONG_SIDE}-long-side frames at detect_conf {cache['conf']}")
|
||||
print(f"image6: {len(shelf_full)} facings, {known} holding a catalog row, "
|
||||
f"{len(shelf_live)} of them cut from the live frame")
|
||||
print(f"distractors: {len(distractors)} crops from {cache['photos']} unrelated shelf photos")
|
||||
print()
|
||||
print(f"{'accept':>6} {'floor':>5} | {'shown':>5} {'corr':>4} {'prec':>6} {'cover':>6} "
|
||||
f"| {'shown':>5} {'corr':>4} {'prec':>6} | {'false':>5} {'rate':>6}")
|
||||
print(f"{'':>6} {'':>5} | {'--- full resolution ---':^25} "
|
||||
f"| {'---- live resolution ----':^25} | {'-- unknown shelves --':^14}")
|
||||
for accept in LOCAL_ACCEPTS:
|
||||
for floor in TEXT_MIN_SHAREDS:
|
||||
policy = DecisionPolicy(
|
||||
local_accept=accept, text_min_shared=floor,
|
||||
signatures=lane.text_lane.signatures,
|
||||
)
|
||||
full_shown, full_correct = _score(lane, policy, shelf_full)
|
||||
live_shown, live_correct = _score(lane, policy, shelf_live)
|
||||
false = _false_accepts(lane, policy, distractors)
|
||||
shown, correct = full_shown, full_correct
|
||||
rate = false / len(distractors) * 100
|
||||
shipped = " <- shipped" if (accept, floor) == (LOCAL_ACCEPT, TEXT_MIN_SHARED) else ""
|
||||
print(
|
||||
f"{accept:>6.1f} {floor:>5} | {shown:>5} {correct:>4} "
|
||||
f"{correct / shown * 100 if shown else 0:>5.1f}% {correct / known * 100:>5.1f}% "
|
||||
f"| {live_shown:>5} {live_correct:>4} "
|
||||
f"{live_correct / live_shown * 100 if live_shown else 0:>5.1f}% "
|
||||
f"| {false:>5} {rate:>5.2f}%{shipped}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -18,6 +18,7 @@ import cv2 # noqa: E402
|
|||
import numpy as np # noqa: E402
|
||||
|
||||
from eval_live import ( # noqa: E402
|
||||
IDENTITY_BACKENDS,
|
||||
EvalInputError,
|
||||
identity_level,
|
||||
labelled_top1,
|
||||
|
|
@ -296,5 +297,42 @@ class IdentityContractTests(unittest.TestCase):
|
|||
{"identity_backend": "matcher"},
|
||||
])
|
||||
|
||||
def test_van_is_accepted_by_the_cli_and_by_a_recording_that_names_it(self):
|
||||
self.assertIn("van", IDENTITY_BACKENDS)
|
||||
self.assertEqual(resolve_identity("van", None), "van")
|
||||
self.assertEqual(recorded_identity([{"identity_backend": "van"}]), "van")
|
||||
root = tempfile.mkdtemp()
|
||||
self.addCleanup(shutil.rmtree, root, ignore_errors=True)
|
||||
stock = os.path.join(root, "stock")
|
||||
os.makedirs(stock)
|
||||
recording = os.path.join(root, "session")
|
||||
os.makedirs(recording)
|
||||
write_jpeg(os.path.join(recording, "000000.jpg"), np.zeros((8, 8, 3), dtype=np.uint8))
|
||||
line = {"frame": 1, "path": "000000.jpg", "tracks": [], "identity_backend": "van"}
|
||||
with open(os.path.join(recording, "index.jsonl"), "w", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(line) + "\n")
|
||||
for extra in ([], ["--identity", "van"]):
|
||||
report_path = os.path.join(root, "eval_live.json")
|
||||
completed = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
os.path.join(current_dir, "eval_live.py"),
|
||||
recording,
|
||||
"--stock-dir",
|
||||
stock,
|
||||
"--json",
|
||||
report_path,
|
||||
*extra,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(completed.returncode, 0, completed.stdout + completed.stderr)
|
||||
with open(report_path, "r", encoding="utf-8") as handle:
|
||||
report = json.load(handle)
|
||||
self.assertEqual(report["identity"]["backend"], "van")
|
||||
self.assertEqual(report["fingerprints"]["model"]["identity"], "van")
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -455,6 +455,35 @@ class LocalScaleTests(unittest.TestCase):
|
|||
self.assertGreater(engine.ocr_calls, 1, "the size-only read must not be cached")
|
||||
engine.shutdown()
|
||||
|
||||
def test_a_failing_lane_is_counted_and_logged(self):
|
||||
"""A lane that raises is a failure, not a crop that matched nothing.
|
||||
|
||||
`_on_classified` substituted an empty answer for the exception, so a
|
||||
run in which `score_crop_lanes` failed on every crop produced tracks,
|
||||
zero labels and a clean log - indistinguishable from a real coverage
|
||||
drop. The counter and the log line are what tell the two apart, and
|
||||
the session has to keep serving frames either way.
|
||||
"""
|
||||
session, engine, _, _ = make_session(engine_cls=LocalScaleEngine)
|
||||
|
||||
def boom(crop, method=None, skip_ocr=False):
|
||||
raise RuntimeError("lane unavailable")
|
||||
|
||||
engine.score_crop_lanes = boom
|
||||
named = None
|
||||
with self.assertLogs("dorayaki.live", level="ERROR") as logs:
|
||||
for frame_id in range(1, 14):
|
||||
tracks = process(session, frame_id)["tracks"]
|
||||
self.assertTrue(tracks, "a failing lane must not take the session down")
|
||||
named = named or next(
|
||||
(t["identity"]["sku_id"] for t in tracks if t["identity"]["sku_id"]), None)
|
||||
self.assertTrue(wait_for(lambda: bool(logs.output)),
|
||||
"the exception must be logged, not swallowed")
|
||||
self.assertGreaterEqual(session.snapshot()["stats"]["classify_failures"], 1,
|
||||
"a lane failure must be counted in the session stats")
|
||||
self.assertIsNone(named, "a failed lane must not name a product")
|
||||
engine.shutdown()
|
||||
|
||||
|
||||
class TagPassIntegrationTests(unittest.TestCase):
|
||||
"""The tag pass must reach the live result without blocking frames."""
|
||||
|
|
|
|||
137
recognization/code/test_score_stream.py
Normal file
137
recognization/code/test_score_stream.py
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
"""
|
||||
Tests for the G1 scorer.
|
||||
|
||||
The gate has two halves - the new lane covers at least as much as Van, and the
|
||||
lower bound of the paired-bootstrap difference clears -2 pp - and the second
|
||||
half is only computable if a scored row still says which facing it matched.
|
||||
These tests pin that pairing and the bound it produces.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
if current_dir not in sys.path:
|
||||
sys.path.insert(0, current_dir)
|
||||
|
||||
from score_stream import ( # noqa: E402
|
||||
G1_BOUND_MIN,
|
||||
correct_facings,
|
||||
paired_bootstrap,
|
||||
score,
|
||||
)
|
||||
|
||||
|
||||
def facing(index, box, label="sku_a"):
|
||||
return {"index": index, "box": list(box), "label": label}
|
||||
|
||||
|
||||
def track(track_id, box, sku="sku_a"):
|
||||
return {"track_id": track_id, "sku_id": sku, "label_box": list(box)}
|
||||
|
||||
|
||||
def report(rows, windows=None):
|
||||
"""A score report shaped the way `score` returns one, without the boxes."""
|
||||
return {"rows": rows, "windows": windows}
|
||||
|
||||
|
||||
def correct(track_id, matched):
|
||||
return {"track_id": track_id, "predicted": "sku_a", "facing": matched,
|
||||
"truth": "sku_a", "iou": 1.0, "outcome": "correct"}
|
||||
|
||||
|
||||
def grid(count):
|
||||
return [facing(i, [i * 20, 0, i * 20 + 10, 10]) for i in range(count)]
|
||||
|
||||
|
||||
class ScoreRowTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.stock = tempfile.TemporaryDirectory()
|
||||
open(os.path.join(self.stock.name, "sku_a.jpg"), "wb").close()
|
||||
self.addCleanup(self.stock.cleanup)
|
||||
|
||||
def test_rows_carry_the_matched_facing(self):
|
||||
facings = [facing(7, [0, 0, 100, 100]), facing(8, [900, 0, 1000, 100])]
|
||||
rows = score([track(1, [0, 0, 100, 100]), track(2, [400, 400, 500, 500])],
|
||||
facings, self.stock.name, 0.5)["rows"]
|
||||
by_track = {row["track_id"]: row for row in rows}
|
||||
self.assertEqual(by_track[1]["facing"], 7)
|
||||
self.assertEqual(by_track[1]["outcome"], "correct")
|
||||
# A track that hit nothing is unpaired, and says so instead of guessing.
|
||||
self.assertIsNone(by_track[2]["facing"])
|
||||
self.assertEqual(by_track[2]["outcome"], "unmatched")
|
||||
|
||||
def test_correct_facings_counts_a_facing_once(self):
|
||||
rows = [correct(1, 7), correct(2, 7), correct(3, 9)]
|
||||
self.assertEqual(correct_facings(rows), {7, 9})
|
||||
|
||||
|
||||
class PairedBootstrapTests(unittest.TestCase):
|
||||
"""27 facings is a small sample, so the pairing is what carries the gate."""
|
||||
|
||||
def test_identical_systems_sit_at_zero_everywhere(self):
|
||||
rows = [correct(1, i) for i in range(10)]
|
||||
result = paired_bootstrap(report(rows), report(rows), [])
|
||||
self.assertEqual(result["difference"], 0.0)
|
||||
self.assertEqual(result["lower_95"], 0.0)
|
||||
self.assertEqual(result["upper_95"], 0.0)
|
||||
self.assertEqual(result["van"], result["new"])
|
||||
|
||||
def test_a_superset_of_vans_hits_can_never_bound_below_zero(self):
|
||||
# The image6 shape: the new lane gets everything Van got, plus a facing.
|
||||
van = [correct(i, i) for i in range(17)]
|
||||
new = van + [correct(99, 17)]
|
||||
result = paired_bootstrap(report(van), report(new), grid(27))
|
||||
self.assertEqual(result["facings"], 27)
|
||||
self.assertEqual(result["van"], 17)
|
||||
self.assertEqual(result["new"], 18)
|
||||
self.assertAlmostEqual(result["difference"], 1 / 27, places=4)
|
||||
# No resample of these 27 can put a facing in the new run that Van's run
|
||||
# did not already have, so the 2.5th percentile is a hard zero. A bound
|
||||
# printed as +0.0 pp is the floor of this pairing, not a margin.
|
||||
self.assertEqual(result["lower_95"], 0.0)
|
||||
self.assertGreaterEqual(result["lower_95"], G1_BOUND_MIN)
|
||||
|
||||
def test_a_swapped_facing_spreads_across_zero(self):
|
||||
van = [correct(i, i) for i in range(17)]
|
||||
new = van[:16] + [correct(99, 17)]
|
||||
result = paired_bootstrap(report(van), report(new), grid(27))
|
||||
self.assertEqual(result["difference"], 0.0)
|
||||
self.assertLess(result["lower_95"], G1_BOUND_MIN)
|
||||
self.assertGreater(result["upper_95"], 0.0)
|
||||
|
||||
def test_both_runs_are_graded_on_what_either_window_reached(self):
|
||||
# Two facings at opposite edges; each run panned to one of them. Grading
|
||||
# on the union keeps both on the same facings; the intersection would
|
||||
# score each run on its own pan and the pairing would be empty.
|
||||
facings = [facing(0, [0, 0, 100, 100]), facing(1, [900, 0, 1000, 100])]
|
||||
left, right = [0, 0, 500, 1000], [500, 0, 1000, 1000]
|
||||
result = paired_bootstrap(report([correct(1, 0)], [left]),
|
||||
report([correct(2, 1)], [right]), facings)
|
||||
self.assertEqual(result["facings"], 2)
|
||||
self.assertEqual((result["van"], result["new"]), (1, 1))
|
||||
self.assertEqual(result["difference"], 0.0)
|
||||
|
||||
def test_the_bound_repeats_for_the_same_seed(self):
|
||||
van = [correct(i, i) for i in range(17)]
|
||||
new = van[:5] + [correct(90 + i, 20 + i) for i in range(9)]
|
||||
facings = grid(27)
|
||||
first = paired_bootstrap(report(van), report(new), facings)
|
||||
self.assertEqual(first, paired_bootstrap(report(van), report(new), facings))
|
||||
|
||||
def test_no_reachable_facing_scores_zero_rather_than_dividing_by_zero(self):
|
||||
# Both runs panned to a corner the only facing is not in: the pairing
|
||||
# set is empty, and a rate is not a number there.
|
||||
elsewhere = [facing(0, [0, 0, 10, 10])]
|
||||
corner = [[500, 500, 600, 600]]
|
||||
result = paired_bootstrap(report([], corner), report([], corner), elsewhere)
|
||||
self.assertEqual(result["facings"], 0)
|
||||
self.assertEqual(result["lower_95"], 0.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -2,129 +2,142 @@
|
|||
"dataset": "wikimedia-commons-2",
|
||||
"catalog": "stock-lopia-clean",
|
||||
"note": "Unrelated shelves: every accept is a false accept by construction.",
|
||||
"images": 17,
|
||||
"detections": 462,
|
||||
"false_accepts": 1,
|
||||
"false_accept_rate": 0.0022,
|
||||
"config": {
|
||||
"frame_long_side": 1280,
|
||||
"detect_conf": 0.1,
|
||||
"local_accept": 4.0,
|
||||
"text_min_shared": 4
|
||||
},
|
||||
"images": 18,
|
||||
"detections": 1076,
|
||||
"false_accepts": 0,
|
||||
"false_accept_rate": 0.0,
|
||||
"rows": [
|
||||
{
|
||||
"file": "000_1920px-2011年4月の牛乳_(5588310773).jpg",
|
||||
"title": "File:2011年4月の牛乳 (5588310773).jpg",
|
||||
"detections": 78,
|
||||
"false_accepts": 1,
|
||||
"ms": 3504
|
||||
"detections": 105,
|
||||
"false_accepts": 0,
|
||||
"ms": 3108
|
||||
},
|
||||
{
|
||||
"file": "001_Candy_in_Japan_2005_(5256472).jpg",
|
||||
"title": "File:Candy in Japan 2005 (5256472).jpg",
|
||||
"detections": 12,
|
||||
"detections": 41,
|
||||
"false_accepts": 0,
|
||||
"ms": 408
|
||||
"ms": 1077
|
||||
},
|
||||
{
|
||||
"file": "002_1920px-Empty_shelves_in_Tokyo_area_as_people_get_ready_for_t.jpg",
|
||||
"title": "File:Empty shelves in Tokyo area as people get ready for typhoon Hagibis - Oct 11 2019 330pm.jpeg",
|
||||
"detections": 8,
|
||||
"detections": 55,
|
||||
"false_accepts": 0,
|
||||
"ms": 101
|
||||
"ms": 637
|
||||
},
|
||||
{
|
||||
"file": "003_1920px-HK_SW_上環_Sheung_Wan_德輔道西_88_Des_Voeux_Road_West_shop_.jpg",
|
||||
"title": "File:HK SW 上環 Sheung Wan 德輔道西 88 Des Voeux Road West shop 惠康超級市場 Wellcome Supermarket goods August 2020 SS2 27.jpg",
|
||||
"detections": 10,
|
||||
"detections": 38,
|
||||
"false_accepts": 0,
|
||||
"ms": 1560
|
||||
"ms": 952
|
||||
},
|
||||
{
|
||||
"file": "005_1920px-Illuminated_wooden_shelf_with_many_glass_jars_contain.jpg",
|
||||
"title": "File:Illuminated wooden shelf with many glass jars containing cookies for sale in Tokyo.jpg",
|
||||
"detections": 41,
|
||||
"detections": 47,
|
||||
"false_accepts": 0,
|
||||
"ms": 2946
|
||||
"ms": 862
|
||||
},
|
||||
{
|
||||
"file": "006_1920px-Interior_of_JUSCO_Shingu_Shopping_Center_20100213c.jpg",
|
||||
"title": "File:Interior of JUSCO Shingu Shopping Center 20100213c.JPG",
|
||||
"detections": 105,
|
||||
"false_accepts": 0,
|
||||
"ms": 1087
|
||||
},
|
||||
{
|
||||
"file": "007_Japan_earthquake_store_shelves.jpg",
|
||||
"title": "File:Japan earthquake store shelves.jpg",
|
||||
"detections": 4,
|
||||
"detections": 11,
|
||||
"false_accepts": 0,
|
||||
"ms": 298
|
||||
"ms": 277
|
||||
},
|
||||
{
|
||||
"file": "009_Milk_(2655574568).jpg",
|
||||
"title": "File:Milk (2655574568).jpg",
|
||||
"detections": 58,
|
||||
"detections": 153,
|
||||
"false_accepts": 0,
|
||||
"ms": 2802
|
||||
"ms": 2720
|
||||
},
|
||||
{
|
||||
"file": "010_Natsuge-mikado_okashi.jpg",
|
||||
"title": "File:Natsuge-mikado okashi.jpg",
|
||||
"detections": 7,
|
||||
"detections": 30,
|
||||
"false_accepts": 0,
|
||||
"ms": 704
|
||||
"ms": 724
|
||||
},
|
||||
{
|
||||
"file": "012_1920px-Supermarket_in_2006_(646332846).jpg",
|
||||
"title": "File:Supermarket in 2006 (646332846).jpg",
|
||||
"detections": 129,
|
||||
"detections": 174,
|
||||
"false_accepts": 0,
|
||||
"ms": 6707
|
||||
"ms": 1950
|
||||
},
|
||||
{
|
||||
"file": "014_1920px-「ペヤング」予想通りの大売れ!_(18634213682).jpg",
|
||||
"title": "File:「ペヤング」予想通りの大売れ! (18634213682).jpg",
|
||||
"detections": 27,
|
||||
"detections": 30,
|
||||
"false_accepts": 0,
|
||||
"ms": 762
|
||||
"ms": 707
|
||||
},
|
||||
{
|
||||
"file": "015_1920px-お客様各位_2008_国内産バター_(2429997079).jpg",
|
||||
"title": "File:お客様各位 2008 国内産バター (2429997079).jpg",
|
||||
"detections": 14,
|
||||
"detections": 28,
|
||||
"false_accepts": 0,
|
||||
"ms": 1499
|
||||
"ms": 1036
|
||||
},
|
||||
{
|
||||
"file": "016_1920px-パン売り場_手動ドア式_2016_(28574754416).jpg",
|
||||
"title": "File:パン売り場 手動ドア式 2016 (28574754416).jpg",
|
||||
"detections": 21,
|
||||
"detections": 63,
|
||||
"false_accepts": 0,
|
||||
"ms": 1842
|
||||
"ms": 1248
|
||||
},
|
||||
{
|
||||
"file": "017_1920px-プラレール_2015_(22339689873).jpg",
|
||||
"title": "File:プラレール 2015 (22339689873).jpg",
|
||||
"detections": 12,
|
||||
"detections": 73,
|
||||
"false_accepts": 0,
|
||||
"ms": 864
|
||||
"ms": 1324
|
||||
},
|
||||
{
|
||||
"file": "018_一家族様一本限り_(5588902836).jpg",
|
||||
"title": "File:一家族様一本限り (5588902836).jpg",
|
||||
"detections": 27,
|
||||
"detections": 41,
|
||||
"false_accepts": 0,
|
||||
"ms": 3880
|
||||
"ms": 1208
|
||||
},
|
||||
{
|
||||
"file": "019_1920px-洗濯洗剤_(10852819193).jpg",
|
||||
"title": "File:洗濯洗剤 (10852819193).jpg",
|
||||
"detections": 8,
|
||||
"detections": 44,
|
||||
"false_accepts": 0,
|
||||
"ms": 704
|
||||
"ms": 606
|
||||
},
|
||||
{
|
||||
"file": "020_1920px-申し訳ございませんが_ごめんなさい_お魚本日の調理は終了いたしました。_どうぞよろしくお願いします_(11.jpg",
|
||||
"title": "File:申し訳ございませんが ごめんなさい お魚本日の調理は終了いたしました。 どうぞよろしくお願いします (11331761984).jpg",
|
||||
"detections": 6,
|
||||
"detections": 25,
|
||||
"false_accepts": 0,
|
||||
"ms": 957
|
||||
"ms": 571
|
||||
},
|
||||
{
|
||||
"file": "021_1920px-要冷蔵_(4513315790).jpg",
|
||||
"title": "File:要冷蔵 (4513315790).jpg",
|
||||
"detections": 0,
|
||||
"detections": 13,
|
||||
"false_accepts": 0,
|
||||
"ms": 190
|
||||
"ms": 259
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -2,325 +2,338 @@
|
|||
"dataset": "wikimedia-commons",
|
||||
"catalog": "stock-lopia-clean",
|
||||
"note": "Unrelated shelves: every accept is a false accept by construction.",
|
||||
"images": 45,
|
||||
"detections": 2133,
|
||||
"false_accepts": 7,
|
||||
"false_accept_rate": 0.0033,
|
||||
"config": {
|
||||
"frame_long_side": 1280,
|
||||
"detect_conf": 0.1,
|
||||
"local_accept": 4.0,
|
||||
"text_min_shared": 4
|
||||
},
|
||||
"images": 46,
|
||||
"detections": 3504,
|
||||
"false_accepts": 3,
|
||||
"false_accept_rate": 0.0009,
|
||||
"rows": [
|
||||
{
|
||||
"file": "000_1920px-198円_中華弁当_2016_(25800617695).jpg",
|
||||
"title": "File:198円 中華弁当 2016 (25800617695).jpg",
|
||||
"detections": 2,
|
||||
"detections": 14,
|
||||
"false_accepts": 0,
|
||||
"ms": 1774
|
||||
"ms": 1669
|
||||
},
|
||||
{
|
||||
"file": "001_1920px-2011年4月の牛乳_(5588310773).jpg",
|
||||
"title": "File:2011年4月の牛乳 (5588310773).jpg",
|
||||
"detections": 78,
|
||||
"false_accepts": 1,
|
||||
"ms": 4553
|
||||
"detections": 105,
|
||||
"false_accepts": 0,
|
||||
"ms": 3225
|
||||
},
|
||||
{
|
||||
"file": "002_1920px-20140909-0252_Costa_Mesa_Mitsuwa.JPG",
|
||||
"title": "File:20140909-0252 Costa Mesa Mitsuwa.JPG",
|
||||
"detections": 95,
|
||||
"detections": 121,
|
||||
"false_accepts": 0,
|
||||
"ms": 3025
|
||||
"ms": 2674
|
||||
},
|
||||
{
|
||||
"file": "003_1920px-Aomori_Home_(4721758999).jpg",
|
||||
"title": "File:Aomori Home (4721758999).jpg",
|
||||
"detections": 76,
|
||||
"detections": 97,
|
||||
"false_accepts": 0,
|
||||
"ms": 3634
|
||||
"ms": 3333
|
||||
},
|
||||
{
|
||||
"file": "004_1920px-Asakusa_15_-_making_rice_crackers_the_old_way_(155797.jpg",
|
||||
"title": "File:Asakusa 15 - making rice crackers the old way (15579727827).jpg",
|
||||
"detections": 14,
|
||||
"detections": 40,
|
||||
"false_accepts": 0,
|
||||
"ms": 162
|
||||
"ms": 456
|
||||
},
|
||||
{
|
||||
"file": "005_1920px-Asakusa_16_-_making_rice_crackers_the_old_way_(155794.jpg",
|
||||
"title": "File:Asakusa 16 - making rice crackers the old way (15579472088).jpg",
|
||||
"detections": 3,
|
||||
"detections": 30,
|
||||
"false_accepts": 0,
|
||||
"ms": 131
|
||||
"ms": 537
|
||||
},
|
||||
{
|
||||
"file": "006_1920px-Asakusa_17_-_making_rice_crackers_the_old_way_(155794.jpg",
|
||||
"title": "File:Asakusa 17 - making rice crackers the old way (15579471588).jpg",
|
||||
"detections": 4,
|
||||
"detections": 30,
|
||||
"false_accepts": 0,
|
||||
"ms": 132
|
||||
"ms": 485
|
||||
},
|
||||
{
|
||||
"file": "007_1920px-Asakusa_19_-_the_woman_selling_the_rice_crackers_(157.jpg",
|
||||
"title": "File:Asakusa 19 - the woman selling the rice crackers (15764889785).jpg",
|
||||
"detections": 3,
|
||||
"detections": 33,
|
||||
"false_accepts": 0,
|
||||
"ms": 67
|
||||
"ms": 502
|
||||
},
|
||||
{
|
||||
"file": "008_1920px-Azabudai-Hills---2025-04-05_015.jpg",
|
||||
"title": "File:Azabudai-Hills---2025-04-05 015.jpg",
|
||||
"detections": 37,
|
||||
"detections": 54,
|
||||
"false_accepts": 2,
|
||||
"ms": 2113
|
||||
"ms": 2525
|
||||
},
|
||||
{
|
||||
"file": "010_1920px-Breathalyzers_in_a_MaxValu_supermarket.jpg",
|
||||
"title": "File:Breathalyzers in a MaxValu supermarket.jpg",
|
||||
"detections": 5,
|
||||
"detections": 24,
|
||||
"false_accepts": 0,
|
||||
"ms": 368
|
||||
"ms": 1508
|
||||
},
|
||||
{
|
||||
"file": "011_1920px-Chocolate_in_Japan_(5137097169).jpg",
|
||||
"title": "File:Chocolate in Japan (5137097169).jpg",
|
||||
"detections": 65,
|
||||
"false_accepts": 1,
|
||||
"ms": 3829
|
||||
"detections": 78,
|
||||
"false_accepts": 0,
|
||||
"ms": 1961
|
||||
},
|
||||
{
|
||||
"file": "013_1920px-Confectionery_at_WCTC_Shopping_Center,_Koror_City,_Pa.jpg",
|
||||
"title": "File:Confectionery at WCTC Shopping Center, Koror City, Palau.jpg",
|
||||
"detections": 96,
|
||||
"detections": 154,
|
||||
"false_accepts": 0,
|
||||
"ms": 6114
|
||||
"ms": 3424
|
||||
},
|
||||
{
|
||||
"file": "014_1920px-Deer_cookies_at_Nara,_Japan.jpg",
|
||||
"title": "File:Deer cookies at Nara, Japan.jpg",
|
||||
"detections": 10,
|
||||
"detections": 24,
|
||||
"false_accepts": 0,
|
||||
"ms": 733
|
||||
"ms": 465
|
||||
},
|
||||
{
|
||||
"file": "015_1920px-ESL_Price_Tag.JPG",
|
||||
"title": "File:ESL Price Tag.JPG",
|
||||
"detections": 32,
|
||||
"detections": 56,
|
||||
"false_accepts": 0,
|
||||
"ms": 1506
|
||||
"ms": 1383
|
||||
},
|
||||
{
|
||||
"file": "017_1920px-Food-with-physiological-energy-value-example.jpg",
|
||||
"title": "File:Food-with-physiological-energy-value-example.jpg",
|
||||
"detections": 10,
|
||||
"detections": 32,
|
||||
"false_accepts": 0,
|
||||
"ms": 454
|
||||
"ms": 1070
|
||||
},
|
||||
{
|
||||
"file": "019_1920px-HK_CWB_Wellcome_Shop_Supermarket_goods_Carbonated_sof.jpg",
|
||||
"title": "File:HK CWB Wellcome Shop Supermarket goods Carbonated soft drink codd-neck bottles 波子汽水 Apple Ramune Japanese products July 2017 IX1.jpg",
|
||||
"detections": 18,
|
||||
"detections": 42,
|
||||
"false_accepts": 0,
|
||||
"ms": 712
|
||||
"ms": 1094
|
||||
},
|
||||
{
|
||||
"file": "021_1920px-Interior_of_Supermarket_in_Japan_02.jpg",
|
||||
"title": "File:Interior of Supermarket in Japan 02.jpg",
|
||||
"detections": 5,
|
||||
"detections": 18,
|
||||
"false_accepts": 0,
|
||||
"ms": 286
|
||||
"ms": 491
|
||||
},
|
||||
{
|
||||
"file": "022_1920px-JP_日本_Japan_沖繩_OKINAWA_浦添市_Urasoe-city_AEON_Supermark.jpg",
|
||||
"title": "File:JP 日本 Japan 沖繩 OKINAWA 浦添市 Urasoe-city AEON Supermarket food drinks February 2026 N13p 02.jpg",
|
||||
"detections": 119,
|
||||
"detections": 165,
|
||||
"false_accepts": 0,
|
||||
"ms": 4603
|
||||
"ms": 3522
|
||||
},
|
||||
{
|
||||
"file": "023_1920px-thumbnail.jpg",
|
||||
"title": "File:JP 日本 Japan 沖繩 OKINAWA 那霸 Naha Omoromachi Shintoshin 新都心 Naha Main Place 商場 Shopping Mall shop Aeon Supermarket drink 益力多 January 2025 R12S 01.jpg",
|
||||
"detections": 117,
|
||||
"detections": 197,
|
||||
"false_accepts": 0,
|
||||
"ms": 2581
|
||||
"ms": 3389
|
||||
},
|
||||
{
|
||||
"file": "024_1920px-JaSupermarket_Instant_noodles.JPG",
|
||||
"title": "File:JaSupermarket Instant noodles.JPG",
|
||||
"detections": 97,
|
||||
"detections": 147,
|
||||
"false_accepts": 0,
|
||||
"ms": 2750
|
||||
"ms": 2744
|
||||
},
|
||||
{
|
||||
"file": "026_1920px-Kawasaki-Inryo-1.jpg",
|
||||
"title": "File:Kawasaki-Inryo-1.jpg",
|
||||
"detections": 12,
|
||||
"detections": 45,
|
||||
"false_accepts": 0,
|
||||
"ms": 892
|
||||
"ms": 2007
|
||||
},
|
||||
{
|
||||
"file": "029_1920px-Miyazu_Mipple_1F_2021-04_ac.jpg",
|
||||
"title": "File:Miyazu Mipple 1F 2021-04 ac.jpg",
|
||||
"detections": 73,
|
||||
"detections": 157,
|
||||
"false_accepts": 0,
|
||||
"ms": 852
|
||||
"ms": 1782
|
||||
},
|
||||
{
|
||||
"file": "030_1920px-Octopus_rice_crackers_No,1.JPG",
|
||||
"title": "File:Octopus rice crackers No,1.JPG",
|
||||
"detections": 5,
|
||||
"detections": 22,
|
||||
"false_accepts": 0,
|
||||
"ms": 247
|
||||
"ms": 556
|
||||
},
|
||||
{
|
||||
"file": "031_1920px-Octopus_senbei.JPG",
|
||||
"title": "File:Octopus senbei.JPG",
|
||||
"detections": 4,
|
||||
"detections": 23,
|
||||
"false_accepts": 0,
|
||||
"ms": 140
|
||||
"ms": 612
|
||||
},
|
||||
{
|
||||
"file": "032_1920px-Okaki_for_sale_in_Kawagoe.jpg",
|
||||
"title": "File:Okaki for sale in Kawagoe.jpg",
|
||||
"detections": 27,
|
||||
"detections": 70,
|
||||
"false_accepts": 0,
|
||||
"ms": 267
|
||||
"ms": 1042
|
||||
},
|
||||
{
|
||||
"file": "033_Okonomiyaki_and_takoyaki_ingredients.jpg",
|
||||
"title": "File:Okonomiyaki and takoyaki ingredients.jpg",
|
||||
"detections": 39,
|
||||
"detections": 55,
|
||||
"false_accepts": 0,
|
||||
"ms": 1228
|
||||
"ms": 2553
|
||||
},
|
||||
{
|
||||
"file": "034_1920px-Rice_toppings_&_additional_ingredients_(5846028686).jpg",
|
||||
"title": "File:Rice toppings & additional ingredients (5846028686).jpg",
|
||||
"detections": 59,
|
||||
"false_accepts": 0,
|
||||
"ms": 1871
|
||||
},
|
||||
{
|
||||
"file": "035_1920px-SW_4963.jpg",
|
||||
"title": "File:SW 4963.jpg",
|
||||
"detections": 5,
|
||||
"detections": 41,
|
||||
"false_accepts": 0,
|
||||
"ms": 200
|
||||
"ms": 755
|
||||
},
|
||||
{
|
||||
"file": "037_1920px-Soy_Sauce_selection_(6362318717).jpg",
|
||||
"title": "File:Soy Sauce selection (6362318717).jpg",
|
||||
"detections": 68,
|
||||
"detections": 85,
|
||||
"false_accepts": 0,
|
||||
"ms": 1671
|
||||
"ms": 1623
|
||||
},
|
||||
{
|
||||
"file": "038_1920px-Soy_sauce_(5846027636).jpg",
|
||||
"title": "File:Soy sauce (5846027636).jpg",
|
||||
"detections": 103,
|
||||
"false_accepts": 1,
|
||||
"ms": 1626
|
||||
"detections": 216,
|
||||
"false_accepts": 0,
|
||||
"ms": 2534
|
||||
},
|
||||
{
|
||||
"file": "039_1920px-Soy_sauce_in_supermarket.JPG",
|
||||
"title": "File:Soy sauce in supermarket.JPG",
|
||||
"detections": 71,
|
||||
"detections": 101,
|
||||
"false_accepts": 0,
|
||||
"ms": 2380
|
||||
"ms": 1884
|
||||
},
|
||||
{
|
||||
"file": "040_1920px-Spice_Supermarket_Japan.JPG",
|
||||
"title": "File:Spice Supermarket Japan.JPG",
|
||||
"detections": 254,
|
||||
"detections": 300,
|
||||
"false_accepts": 0,
|
||||
"ms": 2544
|
||||
"ms": 2994
|
||||
},
|
||||
{
|
||||
"file": "041_1920px-Strong_Zero_4962.jpg",
|
||||
"title": "File:Strong Zero 4962.jpg",
|
||||
"detections": 18,
|
||||
"detections": 45,
|
||||
"false_accepts": 0,
|
||||
"ms": 856
|
||||
"ms": 1731
|
||||
},
|
||||
{
|
||||
"file": "042_1920px-Supermarket_Digital_Displays_(2927051130).jpg",
|
||||
"title": "File:Supermarket Digital Displays (2927051130).jpg",
|
||||
"detections": 89,
|
||||
"detections": 97,
|
||||
"false_accepts": 1,
|
||||
"ms": 3561
|
||||
"ms": 2454
|
||||
},
|
||||
{
|
||||
"file": "043_1920px-Tasty_Food_Abundance_in_Healthy_Europe.png",
|
||||
"title": "File:Tasty Food Abundance in Healthy Europe.png",
|
||||
"detections": 13,
|
||||
"detections": 32,
|
||||
"false_accepts": 0,
|
||||
"ms": 1202
|
||||
"ms": 982
|
||||
},
|
||||
{
|
||||
"file": "047_1920px-Tokyo---Ebisu-and-Shibuya---2026-02-04_162.jpg",
|
||||
"title": "File:Tokyo---Ebisu-and-Shibuya---2026-02-04 162.jpg",
|
||||
"detections": 74,
|
||||
"detections": 81,
|
||||
"false_accepts": 0,
|
||||
"ms": 4770
|
||||
"ms": 1356
|
||||
},
|
||||
{
|
||||
"file": "048_1920px-Tokyo---Ebisu-and-Shibuya---2026-02-04_167.jpg",
|
||||
"title": "File:Tokyo---Ebisu-and-Shibuya---2026-02-04 167.jpg",
|
||||
"detections": 46,
|
||||
"detections": 51,
|
||||
"false_accepts": 0,
|
||||
"ms": 2557
|
||||
"ms": 823
|
||||
},
|
||||
{
|
||||
"file": "050_1920px-Umami_Snack_(41957534).jpeg",
|
||||
"title": "File:Umami Snack (41957534).jpeg",
|
||||
"detections": 33,
|
||||
"detections": 55,
|
||||
"false_accepts": 0,
|
||||
"ms": 3096
|
||||
"ms": 2518
|
||||
},
|
||||
{
|
||||
"file": "051_1920px-このかねをならすのはだーれ_(3079692697).jpg",
|
||||
"title": "File:このかねをならすのはだーれ (3079692697).jpg",
|
||||
"detections": 0,
|
||||
"detections": 3,
|
||||
"false_accepts": 0,
|
||||
"ms": 125
|
||||
"ms": 170
|
||||
},
|
||||
{
|
||||
"file": "052_1920px-スーパーの野菜コーナー_2008_(2785121488).jpg",
|
||||
"title": "File:スーパーの野菜コーナー 2008 (2785121488).jpg",
|
||||
"detections": 25,
|
||||
"detections": 85,
|
||||
"false_accepts": 0,
|
||||
"ms": 1681
|
||||
"ms": 1963
|
||||
},
|
||||
{
|
||||
"file": "055_1920px-中央フード山手店_-_panoramio.jpg",
|
||||
"title": "File:中央フード山手店 - panoramio.jpg",
|
||||
"detections": 15,
|
||||
"detections": 39,
|
||||
"false_accepts": 0,
|
||||
"ms": 263
|
||||
"ms": 689
|
||||
},
|
||||
{
|
||||
"file": "056_1920px-何このリポD人気⁈買い占めてしまおう。_(4025507198).jpg",
|
||||
"title": "File:何このリポD人気⁈買い占めてしまおう。 (4025507198).jpg",
|
||||
"detections": 38,
|
||||
"detections": 72,
|
||||
"false_accepts": 0,
|
||||
"ms": 2912
|
||||
"ms": 2286
|
||||
},
|
||||
{
|
||||
"file": "057_1920px-味噌売り場_(16183474871).jpg",
|
||||
"title": "File:味噌売り場 (16183474871).jpg",
|
||||
"detections": 61,
|
||||
"detections": 76,
|
||||
"false_accepts": 0,
|
||||
"ms": 3355
|
||||
"ms": 2081
|
||||
},
|
||||
{
|
||||
"file": "058_1920px-塩ひかえめ_(25347152904).jpg",
|
||||
"title": "File:塩ひかえめ (25347152904).jpg",
|
||||
"detections": 66,
|
||||
"detections": 86,
|
||||
"false_accepts": 0,
|
||||
"ms": 3043
|
||||
"ms": 2496
|
||||
},
|
||||
{
|
||||
"file": "060_1920px-牛乳_豆乳_2016_(32081175436).jpg",
|
||||
"title": "File:牛乳 豆乳 2016 (32081175436).jpg",
|
||||
"detections": 103,
|
||||
"false_accepts": 1,
|
||||
"ms": 3677
|
||||
"detections": 130,
|
||||
"false_accepts": 0,
|
||||
"ms": 4677
|
||||
},
|
||||
{
|
||||
"file": "061_1920px-表示価格より100円引_(38057600585).jpg",
|
||||
"title": "File:表示価格より100円引 (38057600585).jpg",
|
||||
"detections": 5,
|
||||
"detections": 17,
|
||||
"false_accepts": 0,
|
||||
"ms": 632
|
||||
"ms": 899
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue