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)
|
mean_scores = aggregate_votes(track.observations)
|
||||||
if getattr(self.engine, "identity_scale", "inlier") == "cosine":
|
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
|
# 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
|
# 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(
|
decision = decide_identity(
|
||||||
mean_scores, method=method, scale="cosine",
|
mean_scores, method=method, scale="cosine",
|
||||||
min_score=0.0, min_confidence=0.0,
|
min_score=line, min_confidence=line,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
decision = decide_identity(mean_scores, method=method)
|
decision = decide_identity(mean_scores, method=method)
|
||||||
|
|
|
||||||
|
|
@ -63,13 +63,33 @@ class FakeMatcher:
|
||||||
return self.score_crop(crop)
|
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):
|
class CosineEngine(RecognitionEngine):
|
||||||
"""A gallery-scale engine: score_crop only returns scores the gallery accepted."""
|
"""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
|
@property
|
||||||
def identity_scale(self):
|
def identity_scale(self):
|
||||||
return "cosine"
|
return "cosine"
|
||||||
|
|
||||||
|
def identity_gallery(self):
|
||||||
|
return self._fake_gallery
|
||||||
|
|
||||||
def score_crop(self, crop, method=None):
|
def score_crop(self, crop, method=None):
|
||||||
scores, _ = self.matcher.score_crop(crop, method or "hybrid")
|
scores, _ = self.matcher.score_crop(crop, method or "hybrid")
|
||||||
return scores
|
return scores
|
||||||
|
|
@ -245,6 +265,24 @@ class CosineScaleTests(unittest.TestCase):
|
||||||
"a cosine pair inside the margin must not be named")
|
"a cosine pair inside the margin must not be named")
|
||||||
engine.shutdown()
|
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):
|
class TagPassIntegrationTests(unittest.TestCase):
|
||||||
"""The tag pass must reach the live result without blocking frames."""
|
"""The tag pass must reach the live result without blocking frames."""
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue