Make the catalog hash honest, share the text helpers, drop the duplicate labels
`catalog_hash` hashed the CSV and the view *names*, so replacing `sku_l09.jpg` with a different product's packshot left the hash unchanged and a report could claim the same catalog while every l09 decision in it had moved. It hashes each view's bytes now. `identity_lanes` carried its own `normalize_text` and `bigrams`, near-copies of the ones in `rebuild_catalog` that had already drifted (the lane's table folded ユ to itself, the catalog's did not). The lane imports the catalog's now, so one read scores the same either way. `evidence/image6_v2.json` was byte-identical to `eval/labels/image6.json`; the evidence scripts and the decision document point at the canonical file and the copy is gone. `score_crop`'s docstring now states the contract its callers already depend on: the dict's insertion order is the lane's ranking, and re-sorting by value names a different SKU than the lane chose. Tests: the lane is shared by the classify pool, so a concurrent-ranking test pins that one BFMatcher does not cross crops under eight threads.
This commit is contained in:
parent
fc49ba60e6
commit
b6c87c45ea
36 changed files with 89 additions and 1524 deletions
|
|
@ -566,7 +566,12 @@ class RecognitionEngine:
|
|||
return self._lane
|
||||
|
||||
def score_crop(self, crop: Any, method: Optional[str] = None) -> Dict[str, float]:
|
||||
"""Score one crop, keyed by canonical SKU.
|
||||
"""Score one crop, keyed by canonical SKU, in the lane's rank order.
|
||||
|
||||
The insertion order is the ranking and callers rely on it: the local
|
||||
lane's order comes from its rank key while its value is the margin over
|
||||
the runner-up, so re-sorting by value names a different SKU than the
|
||||
lane chose. `list(scores.items())` is therefore the ranked sequence.
|
||||
|
||||
Every backend groups by SKU before returning. Scoring each reference
|
||||
view separately is what made the demo's default catalog report 22-29 of
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ detector). The hybrid lane also reports shortlist recall, because a true SKU
|
|||
outside the shortlist is a hard ceiling no verifier can lift.
|
||||
|
||||
python code/eval_identity.py \
|
||||
--labels docs/research/2026-09-28-recognition-architecture/evidence/image6_v2.json \
|
||||
--labels eval/labels/image6.json \
|
||||
--image add/image6.jpg --stock-dir stock-lopia-clean --lanes matcher --live
|
||||
|
||||
Labels are {"image", "facings": [{"box", "label"}]} with "unknown" for facings
|
||||
|
|
@ -70,7 +70,7 @@ LIVE_LONG_SIDE = 1280
|
|||
# Pixel equality is not a leak test: a template re-cut from the same photo with
|
||||
# a different box passes a same-shape comparison. A 64x64 grayscale signature
|
||||
# catches most of those, but it is a heuristic, not a proof — measured on
|
||||
# image6_v2 against stock-lopia, re-cuts in the realistic 0.94..1.06 box band
|
||||
# the corrected labels against stock-lopia, re-cuts in the realistic 0.94..1.06 box band
|
||||
# land 7..37 MAD from their facing's signature while the closest pair of
|
||||
# genuinely distinct same-SKU facings is 23.1, so the distributions overlap and
|
||||
# no threshold is clean. 20.0 catches 81% of that band with zero false
|
||||
|
|
@ -116,12 +116,13 @@ def file_hash(path: str) -> Optional[str]:
|
|||
|
||||
|
||||
def catalog_hash(stock_dir: str) -> Optional[str]:
|
||||
"""SHA-256 over the catalog's CSV bytes and its image names.
|
||||
"""SHA-256 over the catalog's CSV bytes and each view's bytes.
|
||||
|
||||
Image bytes are not hashed: a re-encoded view of the same product is the
|
||||
same catalog, and hashing every packshot would make the number move for
|
||||
reasons that do not change a single decision. A changed row or a different
|
||||
set of views does change it.
|
||||
The view bytes are hashed, not just their names. Names alone were not
|
||||
enough: replacing `sku_l09.jpg` with a different product's packshot left
|
||||
the hash unchanged, so a report could claim the same catalog while every
|
||||
l09 decision in it had moved. Re-encoding the same image does move the
|
||||
hash, which is the price of not being able to lie about what was scored.
|
||||
"""
|
||||
digest = hashlib.sha256()
|
||||
csv_path = os.path.join(stock_dir, "stock.csv")
|
||||
|
|
@ -137,8 +138,14 @@ def catalog_hash(stock_dir: str) -> Optional[str]:
|
|||
)
|
||||
except OSError:
|
||||
names = []
|
||||
digest.update(b"\n")
|
||||
digest.update("\n".join(names).encode("utf-8"))
|
||||
for name in names:
|
||||
digest.update(b"\n")
|
||||
digest.update(name.encode("utf-8"))
|
||||
try:
|
||||
with open(os.path.join(stock_dir, name), "rb") as handle:
|
||||
digest.update(handle.read())
|
||||
except OSError:
|
||||
digest.update(b"unreadable")
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
|
|
@ -557,7 +564,7 @@ def shortlist_recall(
|
|||
shortlist already dropped. The denominator is enrolled facings only — a
|
||||
facing whose SKU has no reference cannot be retrieved by any shortlist, so
|
||||
counting it here reports a retrieval failure that is really a catalog gap.
|
||||
Measured on image6_v2, that distinction is 67% vs 98% at top-5.
|
||||
Measured on the corrected labels, that distinction is 67% vs 98% at top-5.
|
||||
"""
|
||||
hits = total = 0
|
||||
misses: List[Dict[str, Any]] = []
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ import math
|
|||
import os
|
||||
import re
|
||||
import sys
|
||||
import unicodedata
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
|
|
@ -38,7 +37,7 @@ if current_dir not in sys.path:
|
|||
import cv2 # noqa: E402
|
||||
import numpy as np # noqa: E402
|
||||
|
||||
from rebuild_catalog import sku_of # noqa: E402
|
||||
from rebuild_catalog import bigrams, normalize_text, sku_of # noqa: E402
|
||||
|
||||
# The local lane's accept line, in the units of `confidence()` below. Measured
|
||||
# on the corrected image6 labels: 2.0 keeps 95% of shown labels correct at 59%
|
||||
|
|
@ -258,23 +257,10 @@ class LocalLane:
|
|||
return {sku: value for sku, value in self.rank(crop)}
|
||||
|
||||
|
||||
# Katakana folding: OCR reads small kana and the long-vowel mark inconsistently,
|
||||
# so キユーピー and キユピー have to land on the same bigrams.
|
||||
_KANA_FOLD = str.maketrans(
|
||||
"ッャュョァィゥェォヮヵヶユ", "ツヤユヨアイウエオワカケユ"
|
||||
)
|
||||
|
||||
|
||||
def normalize_text(text: str) -> str:
|
||||
"""NFKC, uppercase, kana-folded, alphanumeric and `%` only."""
|
||||
text = unicodedata.normalize("NFKC", text).upper()
|
||||
text = text.translate(_KANA_FOLD)
|
||||
text = text.replace("キユーピー", "キユピ").replace("キューピー", "キユピ")
|
||||
return "".join(ch for ch in text if ch.isalnum() or ch == "%")
|
||||
|
||||
|
||||
def bigrams(text: str) -> set:
|
||||
return {text[i:i + 2] for i in range(len(text) - 1)}
|
||||
# Text normalization and bigrams come from `rebuild_catalog`, which uses them
|
||||
# to match a catalog name against a packshot's OCR. Two copies of the same
|
||||
# folding rules drift: the lane's copy folded ユ to itself while the catalog's
|
||||
# did not, so the same read scored differently depending on which one ran.
|
||||
|
||||
|
||||
def identity_bigrams(read: str) -> set:
|
||||
|
|
|
|||
|
|
@ -564,6 +564,51 @@ class DecisionPolicyTests(unittest.TestCase):
|
|||
self.assertEqual(decision["identity_level"], "rejected")
|
||||
|
||||
|
||||
class LocalLaneConcurrencyTests(unittest.TestCase):
|
||||
"""The lane is shared by the classify pool, so `rank` must be thread-safe.
|
||||
|
||||
One `cv2.BFMatcher` is reused across calls. The classify pool runs up to
|
||||
eight workers, so a lane that mutated matcher state per call would return
|
||||
another crop's answer under load - the failure would look like a
|
||||
recognition error, not a crash.
|
||||
"""
|
||||
|
||||
def test_concurrent_ranking_matches_solo_ranking(self):
|
||||
import threading
|
||||
|
||||
import cv2
|
||||
|
||||
from identity_lanes import LocalLane
|
||||
|
||||
stock_dir = os.path.join(os.path.dirname(current_dir), "stock-lopia-clean")
|
||||
if not os.path.isdir(stock_dir):
|
||||
self.skipTest("no catalog checked out")
|
||||
image_path = os.path.join(os.path.dirname(current_dir), "add", "image6.jpg")
|
||||
if not os.path.isfile(image_path):
|
||||
self.skipTest("no evaluation photo checked out")
|
||||
lane = LocalLane(stock_dir)
|
||||
image = cv2.imread(image_path)
|
||||
crops = [image[700:1000, 600 + 40 * i:800 + 40 * i] for i in range(8)]
|
||||
solo = lane.rank(crops[0])
|
||||
results = {}
|
||||
errors = []
|
||||
|
||||
def work(index: int) -> None:
|
||||
try:
|
||||
for _ in range(6):
|
||||
results[index] = lane.rank(crops[index])
|
||||
except Exception as error: # noqa: BLE001 - reported below
|
||||
errors.append(repr(error))
|
||||
|
||||
threads = [threading.Thread(target=work, args=(i,)) for i in range(8)]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
self.assertEqual(errors, [])
|
||||
self.assertEqual(results[0], solo, "a shared matcher must not cross crops")
|
||||
|
||||
|
||||
class CatalogImageTests(unittest.TestCase):
|
||||
def test_catalog_image_path_is_resolved_through_inventory(self):
|
||||
engine, _, _, _ = make_engine()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue