fix: stop re-gating gallery scores in the live path
`score_crop` returns only scores the gallery already accepted: the raw 0.55 line, or the fine-tuned head's own 0.40. The live path then ranked that aggregate with `decide_identity(scale="cosine")`, whose defaults rewrite to COSINE_ACCEPT 0.60 / COSINE_AMBIGUOUS 0.50 - a second, higher line. Every head match between 0.40 and 0.60 was therefore refused or downgraded to ambiguous, which is the live regression: the head's own training views score 0.57-0.77. `engine.inspect` and `_classify_with_gallery` already avoid this by ranking through `decision_for_match`, which applies only the published margin. The live path now does the same: on the cosine scale it passes explicit zero accept lines, so the margin gate is the only extra gate. Regression tests: a 0.45 head-scale match is labelled (fails on the previous code), and a 0.45/0.44 pair inside the margin stays unnamed. Verified: `python -m unittest discover -s code -p 'test_*.py'` - 257 tests, OK, 2 skipped.
This commit is contained in:
parent
1bb1b7158b
commit
84ca113b90
2 changed files with 57 additions and 6 deletions
|
|
@ -1146,9 +1146,17 @@ class LiveSession:
|
||||||
from identity import aggregate_votes, decide_identity
|
from identity import aggregate_votes, decide_identity
|
||||||
|
|
||||||
mean_scores = aggregate_votes(track.observations)
|
mean_scores = aggregate_votes(track.observations)
|
||||||
decision = decide_identity(
|
if getattr(self.engine, "identity_scale", "inlier") == "cosine":
|
||||||
mean_scores, method=method, scale=getattr(self.engine, "identity_scale", "inlier")
|
# 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 = [
|
track.candidates = [
|
||||||
{"sku_id": row.get("sku_id"), "score": row.get("score")}
|
{"sku_id": row.get("sku_id"), "score": row.get("score")}
|
||||||
for row in (decision.get("ranked_candidates") or [])[:3]
|
for row in (decision.get("ranked_candidates") or [])[:3]
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,18 @@ class FakeMatcher:
|
||||||
return self.score_crop(crop)
|
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:
|
class FakeInventory:
|
||||||
"""Stateful stand-in for InventoryManager, so stock assertions are real."""
|
"""Stateful stand-in for InventoryManager, so stock assertions are real."""
|
||||||
|
|
||||||
|
|
@ -94,11 +106,11 @@ class FakeInventory:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def make_session(matcher=None, settings=None):
|
def make_session(matcher=None, settings=None, engine_cls=RecognitionEngine):
|
||||||
detector = FakeDetector()
|
detector = FakeDetector()
|
||||||
matcher = matcher or FakeMatcher()
|
matcher = matcher or FakeMatcher()
|
||||||
engine = RecognitionEngine(detector, matcher, FakeInventory(),
|
engine = engine_cls(detector, matcher, FakeInventory(),
|
||||||
settings=__import__("engine").EngineSettings(classify_workers=2))
|
settings=__import__("engine").EngineSettings(classify_workers=2))
|
||||||
session = LiveSession("test", engine, settings=settings or LiveSettings(method="hybrid"))
|
session = LiveSession("test", engine, settings=settings or LiveSettings(method="hybrid"))
|
||||||
session.refresh_catalog()
|
session.refresh_catalog()
|
||||||
return session, engine, detector, matcher
|
return session, engine, detector, matcher
|
||||||
|
|
@ -203,6 +215,37 @@ class LabelLifecycleTests(unittest.TestCase):
|
||||||
engine.shutdown()
|
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):
|
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