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.
This commit is contained in:
parent
84ca113b90
commit
004212a8cf
2 changed files with 48 additions and 3 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue