fix: gate the live cosine aggregate at the gallery's own line
Some checks failed
tests / model-free (pull_request) Has been cancelled

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.
This commit is contained in:
kiyo9w 2026-09-28 17:36:43 +09:00
parent 84ca113b90
commit 004212a8cf
2 changed files with 48 additions and 3 deletions

View file

@ -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)

View file

@ -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."""