diff --git a/recognization/code/live.py b/recognization/code/live.py index f8d40fc..4db7960 100644 --- a/recognization/code/live.py +++ b/recognization/code/live.py @@ -1146,9 +1146,17 @@ class LiveSession: from identity import aggregate_votes, decide_identity mean_scores = aggregate_votes(track.observations) - decision = decide_identity( - mean_scores, method=method, scale=getattr(self.engine, "identity_scale", "inlier") - ) + if getattr(self.engine, "identity_scale", "inlier") == "cosine": + # score_crop only returns a score the gallery already accepted, so + # the aggregate must not be re-gated at COSINE_ACCEPT: that line is + # above the head's own 0.40 and refused matches the gallery had + # accepted. Only the margin gate is left to apply. + decision = decide_identity( + mean_scores, method=method, scale="cosine", + min_score=0.0, min_confidence=0.0, + ) + else: + decision = decide_identity(mean_scores, method=method) track.candidates = [ {"sku_id": row.get("sku_id"), "score": row.get("score")} for row in (decision.get("ranked_candidates") or [])[:3] diff --git a/recognization/code/test_live.py b/recognization/code/test_live.py index f43c883..8b1c80d 100644 --- a/recognization/code/test_live.py +++ b/recognization/code/test_live.py @@ -63,6 +63,18 @@ class FakeMatcher: return self.score_crop(crop) +class CosineEngine(RecognitionEngine): + """A gallery-scale engine: score_crop only returns scores the gallery accepted.""" + + @property + def identity_scale(self): + return "cosine" + + def score_crop(self, crop, method=None): + scores, _ = self.matcher.score_crop(crop, method or "hybrid") + return scores + + class FakeInventory: """Stateful stand-in for InventoryManager, so stock assertions are real.""" @@ -94,11 +106,11 @@ class FakeInventory: return True -def make_session(matcher=None, settings=None): +def make_session(matcher=None, settings=None, engine_cls=RecognitionEngine): detector = FakeDetector() matcher = matcher or FakeMatcher() - engine = RecognitionEngine(detector, matcher, FakeInventory(), - settings=__import__("engine").EngineSettings(classify_workers=2)) + engine = engine_cls(detector, matcher, FakeInventory(), + settings=__import__("engine").EngineSettings(classify_workers=2)) session = LiveSession("test", engine, settings=settings or LiveSettings(method="hybrid")) session.refresh_catalog() return session, engine, detector, matcher @@ -203,6 +215,37 @@ class LabelLifecycleTests(unittest.TestCase): engine.shutdown() +class CosineScaleTests(unittest.TestCase): + """A gallery-scale score is gated by the gallery, not re-gated by the live path.""" + + def test_a_head_scale_match_is_labelled(self): + """0.45 clears the head's own 0.40 line but not COSINE_ACCEPT (0.60). + + score_crop only returns scores the gallery already accepted, so the live + aggregate must not re-apply the cosine accept line: that refused every + head match the gallery had accepted, which is the live regression. + """ + session, engine, _, _ = make_session(FakeMatcher(score=0.45), engine_cls=CosineEngine) + for frame_id in range(1, 5): + process(session, frame_id) + self.assertTrue(wait_for(lambda: any( + track["identity"]["state"] == "recognized" for track in process(session, 5)["tracks"] + )), "a gallery-accepted head match must be labelled") + engine.shutdown() + + def test_a_close_cosine_pair_stays_ambiguous(self): + """The margin gate still applies: two near-equal scores must not name a SKU.""" + session, engine, _, _ = make_session(FakeMatcher(score=0.45), engine_cls=CosineEngine) + engine.matcher.score_crop = lambda crop, method=None: {"sku_a": 0.45, "other": 0.44} + for frame_id in range(1, 8): + process(session, frame_id) + result = process(session, 9) + for track in result["tracks"]: + self.assertIsNone(track["identity"]["sku_id"], + "a cosine pair inside the margin must not be named") + engine.shutdown() + + class TagPassIntegrationTests(unittest.TestCase): """The tag pass must reach the live result without blocking frames."""