product-recognition-demo/recognization/code/test_engine.py
tngo ff11529476 fix: cut whole quantity tokens, and gate the fill-in by default
Review 2, item 2. The first version of this gate counted bigrams with a
non-digit character, which let the unit's own letters through: "400g 50%"
left "0G" and "0%", "450g カロリー50%" left "0%", and "500ml" left
"0M". All three still named products.

The quantity token is now cut whole - digits plus unit, in either script -
before counting, and the floor is three bigrams rather than two, because
"カロリ" is two and it is a descriptor every low-calorie package prints.

DecisionPolicy.decide gated the fill-in only when a read was passed, so the
unsafe behaviour was the default. Omitting the read now means no fill-in: a
caller with a ranking and no read has not shown the text can name anything.
TextLane.carries_identity was a dead duplicate of the module function; deleted.

Tests: the five cases from the review plus the default-gate case. Suite 284 ->
286. Evaluator unchanged: local 61%/77% full, 59%/95% live.
2026-09-28 23:46:03 +09:00

530 lines
21 KiB
Python

"""
Contract tests for the recognition engine.
No YOLO weights and no network: a fake detector and a fake matcher stand in for
the model, so these tests defend the engine's own contracts - normalization,
locking, isolation between clients, empty-slot geometry, and the still-image
store.
"""
from __future__ import annotations
import os
import sys
import threading
import time
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 engine import ( # noqa: E402
EngineSettings,
ImageExpired,
ImageStore,
RecognitionEngine,
UndecodableImage,
UnknownMethod,
identity_payload,
)
class FakeDetector:
"""Records concurrency and returns fixed boxes."""
weights_path = "fake.pt"
def __init__(self, boxes=None):
self.boxes = boxes if boxes is not None else [
{"id": 0, "box": [10, 10, 60, 90], "confidence": 0.9, "class_id": 0,
"width": 50, "height": 80, "center": [35, 50]},
{"id": 1, "box": [70, 10, 120, 90], "confidence": 0.8, "class_id": 0,
"width": 50, "height": 80, "center": [95, 50]},
]
self.concurrent = 0
self.max_concurrent = 0
self.calls = 0
self.lock = threading.Lock()
def detect(self, image, conf_threshold=0.25, iou_threshold=0.45):
with self.lock:
self.calls += 1
self.concurrent += 1
self.max_concurrent = max(self.max_concurrent, self.concurrent)
time.sleep(0.01)
with self.lock:
self.concurrent -= 1
return {"status": "success", "boxes": [dict(b) for b in self.boxes]}
def detect_empty_slots(self, image_shape, boxes, min_gap_ratio=0.75):
# One gap between the two boxes, expressed in the frame the engine hands
# over. Scaling with that frame is what makes the normalization test
# meaningful: the engine must translate the answer back to the client's
# pixel space.
height, width = int(image_shape[0]), int(image_shape[1])
x1, x2 = int(width * 0.30), int(width * 0.35)
return [{
"id": "empty_1",
"box": [x1, int(height * 0.10), x2, int(height * 0.90)],
"width": x2 - x1,
"height": int(height * 0.80),
"missing_items_est": 1,
"center": [(x1 + x2) // 2, height // 2],
"row_index": 1,
}]
class FakeMatcher:
def __init__(self):
self.calls = 0
def score_crop(self, crop, method="hybrid"):
self.calls += 1
time.sleep(0.01)
return {"sku_a": 40.0, "sku_b": 2.0}, {"sku_a": {"score": 40.0}}
def score_crop_fast(self, crop, shortlist=5):
return self.score_crop(crop)
def classify_crop(self, crop, method="hybrid"):
from identity import decide_identity
scores, details = self.score_crop(crop, method=method)
return decide_identity(scores, details=details, method=method)
def generate_match_visualization(self, crop, sku, method="surf", max_matches=30):
return crop
class FakeInventory:
def __init__(self):
self.products = {
"sku_a": {"sku_id": "sku_a", "product_name": "Alpha", "price": 100.0,
"number_in": 10, "number_out": 2, "number_on": 8,
"receipt_date": "2026-01-01", "expiration_date": "2026-12-31",
"local_image_path": None},
"sku_b": {"sku_id": "sku_b", "product_name": "Beta", "price": 200.0,
"number_in": 5, "number_out": 1, "number_on": 4,
"receipt_date": "2026-01-02", "expiration_date": "2026-12-30",
"local_image_path": None},
}
self.lock = threading.Lock()
def list_all_skus(self):
return list(self.products)
def get_product(self, sku_id):
return self.products.get(sku_id)
def update_stock(self, sku_id, delta):
with self.lock:
product = self.products.get(sku_id)
if product is None:
return False
product["number_on"] = max(0, product["number_on"] + delta)
if delta < 0:
product["number_out"] += abs(delta)
else:
product["number_in"] += delta
return True
def make_engine(**overrides):
# The fake matcher is what these tests exercise, so the backend is pinned to
# `matcher`; the shipped default is the `local` lane, which reads real
# reference images and would ignore the fake.
overrides.setdefault("identity_backend", "matcher")
settings = EngineSettings(device="cpu", classify_workers=4, **overrides)
detector = FakeDetector()
matcher = FakeMatcher()
inventory = FakeInventory()
engine = RecognitionEngine(detector, matcher, inventory, settings=settings)
return engine, detector, matcher, inventory
def make_image(width=200, height=120):
import numpy as np
return np.zeros((height, width, 3), dtype="uint8")
class EngineBasicsTests(unittest.TestCase):
def test_detect_normalizes_and_reindexes(self):
engine, _, _, _ = make_engine()
boxes = engine.detect(make_image())
self.assertEqual(len(boxes), 2)
self.assertEqual([b["id"] for b in boxes], [0, 1])
self.assertEqual(boxes[0]["width"], 50)
self.assertEqual(boxes[0]["center"], [35, 50])
def test_detector_calls_never_overlap(self):
engine, detector, _, _ = make_engine()
threads = [threading.Thread(target=engine.detect, args=(make_image(),)) for _ in range(8)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
self.assertEqual(detector.calls, 8)
self.assertEqual(detector.max_concurrent, 1, "the model must be used by one caller at a time")
def test_unknown_method_is_rejected(self):
engine, _, _, _ = make_engine()
with self.assertRaises(UnknownMethod):
engine.score_crop(make_image(), method="not-a-method")
def test_undecodable_payload_raises(self):
engine, _, _, _ = make_engine()
with self.assertRaises(UndecodableImage):
engine.decode_image(b"this is not an image")
def test_crop_is_a_copy(self):
engine, _, _, _ = make_engine()
image = make_image()
crop = engine.crop(image, [0, 0, 10, 10])
crop[0, 0, 0] = 255
self.assertEqual(int(image[0, 0, 0]), 0)
class EngineClassificationTests(unittest.TestCase):
def test_classify_boxes_accepts_detection_dicts_without_size_fields(self):
"""The API hands classify_boxes stored detections, which carry no width/height."""
engine, _, _, _ = make_engine()
detections = [
{"id": 0, "box": [10, 10, 60, 90], "confidence": 0.9, "identity": identity_payload(None)},
{"id": 1, "box": [70, 10, 120, 90], "confidence": 0.8, "identity": identity_payload(None)},
]
rows = engine.classify_boxes(make_image(), detections, method="hybrid")
self.assertEqual(len(rows), 2)
self.assertEqual(rows[0]["identity"]["state"], "recognized")
self.assertEqual(rows[0]["identity"]["sku_id"], "sku_a")
def test_a_failing_crop_reports_unrecognized_not_pending(self):
"""One bad crop must not fail the batch, and must not read as pending.
`identity_payload(None)` is the "not yet classified" shape; an
operational failure has to stay distinguishable from absence.
"""
engine, _, matcher, _ = make_engine()
def explode(crop, method=None):
raise RuntimeError("classifier blew up")
matcher.score_crop = explode
rows = engine.classify_boxes(make_image(), [
{"id": 0, "box": [10, 10, 60, 90], "confidence": 0.9},
], method="hybrid")
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["identity"]["state"], "unrecognized")
self.assertIsNone(rows[0]["identity"]["sku_id"])
def test_parallel_classification_matches_sequential(self):
engine, _, matcher, _ = make_engine()
boxes = [
{"id": index, "box": [10 + index, 10, 60 + index, 90], "confidence": 0.9,
"width": 50, "height": 80, "center": [35, 50]}
for index in range(12)
]
parallel = engine.classify_boxes(make_image(), boxes, method="hybrid")
sequential = [engine.score_crop(engine.crop(make_image(), box["box"]), "hybrid") for box in boxes]
self.assertEqual(len(parallel), 12)
self.assertEqual(len(sequential), 12)
for row in parallel:
self.assertEqual(row["identity"]["sku_id"], "sku_a")
def test_summary_counts_states(self):
engine, _, _, _ = make_engine()
detections = [
{"identity": {"state": "recognized", "sku_id": "sku_a"}},
{"identity": {"state": "recognized", "sku_id": "sku_a"}},
{"identity": {"state": "needs_confirmation", "sku_id": None}},
{"identity": {"state": "unrecognized", "sku_id": None}},
]
summary = engine.summarize(detections, [])
self.assertEqual(summary["recognized"], 2)
self.assertEqual(summary["needs_confirmation"], 1)
self.assertEqual(summary["unrecognized"], 1)
self.assertEqual(summary["by_sku"], [{"sku_id": "sku_a", "facings": 2}])
class EmptySlotNormalizationTests(unittest.TestCase):
def test_slots_scale_with_the_frame(self):
"""The detector's thresholds are absolute pixels, so a half-size frame must
produce the same slots (scaled) as the reference-size frame."""
engine, _, _, _ = make_engine()
boxes_small = [
{"box": [5, 5, 30, 45]},
{"box": [35, 5, 60, 45]},
]
slots_small = engine.empty_slots((60, 100), boxes_small)
boxes_big = [
{"box": [10, 10, 60, 90]},
{"box": [70, 10, 120, 90]},
]
slots_big = engine.empty_slots((120, 200), boxes_big)
self.assertEqual(len(slots_small), 1)
self.assertEqual(len(slots_big), 1)
# the small frame is scaled up to the reference width, so the returned
# slot comes back in the small frame's own pixels
self.assertLess(slots_small[0]["box"][2], slots_big[0]["box"][2])
class ImageStoreTests(unittest.TestCase):
def test_roundtrip_and_expiry(self):
store = ImageStore(max_items=2, ttl_s=0.05, max_bytes=10_000_000)
stored = store.put(make_image())
self.assertEqual(store.get(stored.image_id).width, 200)
time.sleep(0.08)
with self.assertRaises(ImageExpired):
store.get(stored.image_id)
def test_bounded_by_count(self):
store = ImageStore(max_items=2, ttl_s=60, max_bytes=10_000_000)
ids = [store.put(make_image()).image_id for _ in range(4)]
with self.assertRaises(ImageExpired):
store.get(ids[0])
self.assertEqual(store.get(ids[-1]).width, 200)
class CatalogTests(unittest.TestCase):
def test_catalog_and_stock_update(self):
engine, _, _, _ = make_engine()
catalog = engine.catalog()
self.assertEqual(len(catalog), 2)
self.assertEqual(catalog[0]["image_url"], "/v1/catalog/sku_a/image")
product = engine.update_stock("sku_a", -1)
self.assertEqual(product["number_on"], 7)
self.assertEqual(product["number_out"], 3)
self.assertIsNone(engine.update_stock("nope", -1))
class GalleryLoadTests(unittest.TestCase):
def test_a_failed_gallery_load_returns_none_and_is_not_retried(self):
engine, _, _, _ = make_engine(identity_backend="gallery", stock_dir="does-not-exist")
class BrokenGallery:
def __init__(self, *args, **kwargs):
pass
def load(self):
raise FileNotFoundError("DINOv2 weights not found")
import gallery as gallery_module
original = gallery_module.ProductGallery
gallery_module.ProductGallery = BrokenGallery
try:
self.assertIsNone(engine.identity_gallery())
self.assertIsNone(engine.identity_gallery())
finally:
gallery_module.ProductGallery = original
engine.shutdown()
self.assertIs(engine._gallery, False)
class LocalLaneTests(unittest.TestCase):
"""The shipped default backend, which the fake matcher cannot exercise.
A two-SKU catalog of real images is built in a temp dir, so the lane reads
genuine reference files and the decision is the one production makes.
"""
def make_catalog(self):
import tempfile
import cv2
import numpy as np
root = tempfile.mkdtemp(prefix="lane-catalog-")
rng = np.random.default_rng(7)
for sku, base in (("sku_a", (200, 60, 60)), ("sku_b", (60, 60, 200))):
image = np.zeros((400, 300, 3), dtype="uint8")
image[:, :] = base
# Structured, high-contrast marks: SIFT needs corners, not noise.
for index in range(12):
x = int(rng.integers(10, 280))
y = int(rng.integers(10, 380))
cv2.rectangle(image, (x, y), (x + 40, y + 40), (255, 255, 255), 3)
cv2.putText(image, sku[-1].upper(), (x + 5, y + 30),
cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 0, 0), 2)
cv2.imwrite(os.path.join(root, f"{sku}.jpg"), image)
with open(os.path.join(root, "stock.csv"), "w", encoding="utf-8") as handle:
handle.write("SKU_ID,Product_Name,Price,Number_In,Number_Out,Number_On\n")
handle.write("sku_a,Alpha,100,1,0,1\n")
handle.write("sku_b,Beta,200,1,0,1\n")
return root
def test_decide_crop_names_the_matching_sku(self):
import cv2
root = self.make_catalog()
engine, _, _, _ = make_engine(identity_backend="local", stock_dir=root)
try:
self.assertEqual(engine.identity_scale, "local")
self.assertIsNotNone(engine.identity_lane())
# The reference itself is the easiest possible query for its own lane.
crop = cv2.imread(os.path.join(root, "sku_a.jpg"))
decision = engine.decide_crop(crop)
self.assertEqual(decision.get("best_sku"), "sku_a")
self.assertEqual(decision.get("identity_level"), "accepted")
finally:
engine.shutdown()
def test_score_crop_is_keyed_by_canonical_sku(self):
import cv2
root = self.make_catalog()
engine, _, _, _ = make_engine(identity_backend="local", stock_dir=root)
try:
scores = engine.score_crop(cv2.imread(os.path.join(root, "sku_b.jpg")))
self.assertTrue(scores)
# No view-suffixed ids: every key is a catalog SKU.
self.assertTrue(all("_" not in sku[4:] for sku in scores), scores)
finally:
engine.shutdown()
def test_a_featureless_crop_is_not_named(self):
import numpy as np
root = self.make_catalog()
engine, _, _, _ = make_engine(identity_backend="local", stock_dir=root)
try:
blank = np.zeros((200, 150, 3), dtype="uint8")
decision = engine.decide_crop(blank)
self.assertIsNone(decision.get("best_sku"))
finally:
engine.shutdown()
def test_warm_up_builds_both_lanes(self):
root = self.make_catalog()
engine, _, _, _ = make_engine(identity_backend="local", stock_dir=root)
try:
engine.warm_up()
# Built eagerly, so the first live frame never pays for them.
self.assertIsNotNone(engine._lane)
self.assertIsNotNone(engine._text_lane)
finally:
engine.shutdown()
class DecisionPolicyTests(unittest.TestCase):
"""The two jobs the text lane does, and the boundary between them."""
def policy(self):
from identity_lanes import DecisionPolicy
return DecisionPolicy()
def test_local_answer_stands_when_the_text_agrees(self):
decision = self.policy().decide([("sku_a", 5.0)], [("sku_a", 0.9)])
self.assertEqual(decision["best_sku"], "sku_a")
self.assertEqual(decision["source"], "local")
def test_text_vetoes_a_local_answer_naming_another_row(self):
"""The near-unknown case: real geometry, different words.
The veto blocks the local answer, and the text lane then names its own
row - the two jobs compose, so the result is the text's SKU, never the
vetoed local one.
"""
decision = self.policy().decide(
[("sku_a", 5.0)], [("sku_b", 0.9)], text_read="コクうま 65%"
)
self.assertEqual(decision["best_sku"], "sku_b")
self.assertEqual(decision["source"], "text")
def test_text_mentioning_the_local_candidate_is_not_a_veto(self):
"""A crop reading "マヨネーズ" scores every mayonnaise row.
The local candidate is usually one of them, so a veto needs the text to
*prefer* another row, not merely to mention it: here the text mentions
sku_b but explains sku_a better, so the local answer stands.
"""
decision = self.policy().decide([("sku_a", 5.0)], [("sku_b", 0.4), ("sku_a", 0.45)])
self.assertEqual(decision["best_sku"], "sku_a")
self.assertEqual(decision["source"], "local")
def test_the_lanes_own_order_wins_over_the_value_order(self):
"""Order and value are different quantities on the local lane.
The order comes from `rank_key` (inliers weighted by hue); the value is
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.
"""
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):
"""Size and number tokens are not identity text."""
decision = self.policy().decide([], [("sku_b", 0.9)], text_read="400g")
self.assertIsNone(decision["best_sku"])
def test_a_size_only_read_still_vetoes(self):
"""The veto keeps "65%" usable: the local lane supplied the evidence."""
decision = self.policy().decide(
[("sku_a", 5.0)], [("sku_b", 0.9)], text_read="65%"
)
self.assertIsNone(decision["best_sku"])
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")
def test_text_fills_in_when_local_has_nothing(self):
decision = self.policy().decide([], [("sku_b", 0.9)], text_read="コクうま 65%")
self.assertEqual(decision["best_sku"], "sku_b")
self.assertEqual(decision["source"], "text")
def test_a_ranking_without_a_read_does_not_fill_in(self):
"""Omitting the read is the safe default, not an ungated one.
A caller that has a ranking and no read has not shown the text can name
anything, so the fill-in job stays closed.
"""
decision = self.policy().decide([], [("sku_b", 0.9)])
self.assertIsNone(decision["best_sku"])
def test_quantity_strings_are_not_names(self):
"""Every pure quantity string, including the ones a digits-only rule let through.
"400g 50%" and "450g カロリー50%" survived the first version of this
gate: it counted non-digit bigrams, and the unit's own letters ("0G",
"0%") are non-digits. Cutting the whole quantity token is what closes
it. "450g カロリー50%" still passes, and correctly so - "カロリー" is
four characters of real text, and it is the text lane's job to find
that no catalog row is named by it.
"""
from identity_lanes import carries_identity
for read in ("400g", "400g 50%", "500ml", "65%", "3日", "中辛"):
with self.subTest(read=read):
self.assertFalse(carries_identity(read), f"{read!r} is not a name")
for read in ("コクうま 65%", "450g カロリー50%"):
with self.subTest(read=read):
self.assertTrue(carries_identity(read), f"{read!r} carries name text")
def test_text_below_its_own_line_does_not_fill_in(self):
decision = self.policy().decide([], [("sku_b", 0.3)])
self.assertIsNone(decision["best_sku"])
def test_a_close_text_pair_does_not_fill_in(self):
"""The margin gate: two rows the text explains equally must not name one."""
decision = self.policy().decide([], [("sku_b", 0.9), ("sku_c", 0.85)])
self.assertIsNone(decision["best_sku"])
def test_no_evidence_is_rejected(self):
decision = self.policy().decide([], [])
self.assertIsNone(decision["best_sku"])
self.assertEqual(decision["identity_level"], "rejected")
class CatalogImageTests(unittest.TestCase):
def test_catalog_image_path_is_resolved_through_inventory(self):
engine, _, _, _ = make_engine()
self.assertIsNone(engine.catalog_image_path("../../etc/passwd"))
self.assertIsNone(engine.catalog_image_path("sku_a"))
if __name__ == "__main__":
unittest.main()