feat(identity): local-feature matcher plus text lane as the default backend
Replaces the DINO gallery as the recognition path with the measured design from docs/research/2026-09-28-recognition-architecture/DECISION.md: RootSIFT over a per-SKU reference pyramid, similarity-constrained RANSAC and a hue check, fused with an OCR text lane that vetoes a match whose package text names a different catalog row and answers on its own when geometry finds nothing. - identity_lanes.py: LocalLane, TextLane, DecisionPolicy. The text lane builds each row's signature from the catalog name plus its packshot OCR, so a row with no enrolled image is still answerable. - engine.py: every backend groups scores by canonical SKU, so no view-suffixed id reaches the wire and a name and price always resolve. decide_crop fuses the lanes; score_crop_lanes feeds the live path. Both lanes are built in warm_up (non-fatally) so the first frame is not the slow one, and the text lane shares the engine's OCR lock. - live.py: decides per crop on the lane's own rank order. The lane's order comes from its rank key and its value is only the gate, so re-sorting by value named a different SKU than the lane chose; averaging in an empty observation also diluted a good confidence below the accept line. - identity.py: a `local` score scale whose gate is one absolute line, because the value is already a margin over the runner-up. - app.py, run-demo-server.sh: `--identity local` is the default; the demo server no longer starts on the leaked `stock-lopia` catalog. - eval_live.py: the replay vocabulary accepts `local`, so a recording from the shipped server can be scored. - README.md: documents the three backends and what each is for. Measured on the corrected image6 labels at live resolution: 36/61 in-catalog facings named at 95% precision, 0 of 48 unknowns accepted, 11 labels sourced from the text lane alone. Through the real server: /v1/recognize 90% precision, /v1/stream 100% precision, no view-suffixed ids on either.
This commit is contained in:
parent
25dbf132e7
commit
aaed5d388f
44 changed files with 7847 additions and 60 deletions
|
|
@ -131,6 +131,10 @@ class FakeInventory:
|
|||
|
||||
|
||||
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()
|
||||
|
|
@ -195,6 +199,25 @@ class EngineClassificationTests(unittest.TestCase):
|
|||
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 = [
|
||||
|
|
@ -299,6 +322,90 @@ class GalleryLoadTests(unittest.TestCase):
|
|||
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 CatalogImageTests(unittest.TestCase):
|
||||
def test_catalog_image_path_is_resolved_through_inventory(self):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue