From 004212a8cfc03eabebf5f3163b07afc7cb5c7dda Mon Sep 17 00:00:00 2001 From: kiyo9w Date: Mon, 28 Sep 2026 17:36:43 +0900 Subject: [PATCH] fix: gate the live cosine aggregate at the gallery's own line The previous commit stopped the live path re-gating gallery scores at COSINE_ACCEPT, but it passed zero accept lines. `aggregate_votes` divides by the whole observation window, so one rejected crop beside one accepted 0.45 averages to 0.225 - and a zero gate labelled that, at a score the gallery itself calls unknown. The aggregate now clears the gallery's own line: `head_threshold` (0.40) when a fine-tuned head is loaded, `threshold` (0.55) otherwise. The margin gate is still the only extra gate, so a head match between 0.40 and 0.60 is labelled. Regression test: a rejected crop followed by an accepted 0.45 must not label the track (fails on the zero-gate version). Verified: `python -m unittest discover -s code -p 'test_*.py'` - 258 tests, OK, 2 skipped. --- recognization/code/live.py | 13 ++++++++--- recognization/code/test_live.py | 38 +++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/recognization/code/live.py b/recognization/code/live.py index 4db7960..b216adb 100644 --- a/recognization/code/live.py +++ b/recognization/code/live.py @@ -1147,13 +1147,20 @@ class LiveSession: mean_scores = aggregate_votes(track.observations) if getattr(self.engine, "identity_scale", "inlier") == "cosine": - # score_crop only returns a score the gallery already accepted, so + # score_crop only returns scores 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. + # accepted. It still has to clear the gallery's own line, because + # aggregate_votes divides by the whole window - one rejected crop + # beside one accepted 0.45 would otherwise average to 0.225 and be + # labelled at a score the gallery calls unknown. + gallery = self.engine.identity_gallery() + line = 0.0 + if gallery is not None: + line = gallery.head_threshold if gallery.head_loaded() else gallery.threshold decision = decide_identity( mean_scores, method=method, scale="cosine", - min_score=0.0, min_confidence=0.0, + min_score=line, min_confidence=line, ) else: decision = decide_identity(mean_scores, method=method) diff --git a/recognization/code/test_live.py b/recognization/code/test_live.py index 8b1c80d..a4dbf7f 100644 --- a/recognization/code/test_live.py +++ b/recognization/code/test_live.py @@ -63,13 +63,33 @@ class FakeMatcher: return self.score_crop(crop) +class FakeGallery: + """The gallery's own accept line, which the live aggregate must clear.""" + + head_threshold = 0.40 + threshold = 0.55 + + def __init__(self, head=True): + self._head = head + + def head_loaded(self): + return self._head + + class CosineEngine(RecognitionEngine): """A gallery-scale engine: score_crop only returns scores the gallery accepted.""" + def __init__(self, *args, head=True, **kwargs): + super().__init__(*args, **kwargs) + self._fake_gallery = FakeGallery(head=head) + @property def identity_scale(self): return "cosine" + def identity_gallery(self): + return self._fake_gallery + def score_crop(self, crop, method=None): scores, _ = self.matcher.score_crop(crop, method or "hybrid") return scores @@ -245,6 +265,24 @@ class CosineScaleTests(unittest.TestCase): "a cosine pair inside the margin must not be named") engine.shutdown() + def test_a_rejected_crop_cannot_dilute_an_accepted_one(self): + """aggregate_votes divides by the whole window, so a miss must not lower the line. + + A rejected crop first, then an accepted 0.45: the mean is 0.225. Gating the + aggregate at zero would label that, at a score the gallery calls unknown. + """ + session, engine, _, _ = make_session(FakeMatcher(score=0.45), engine_cls=CosineEngine) + answers = iter([{}, {"sku_a": 0.45}]) + engine.matcher.score_crop = lambda crop, method=None: (next(answers, {}), {}) + # The retry needs retry_gap frames before the second attempt is eligible. + for frame_id in range(1, 12): + process(session, frame_id) + result = process(session, 13) + for track in result["tracks"]: + self.assertIsNone(track["identity"]["sku_id"], + "a diluted mean must not be labelled below the gallery's line") + engine.shutdown() + class TagPassIntegrationTests(unittest.TestCase): """The tag pass must reach the live result without blocking frames."""