The live path abstained too much: a detected product whose best candidate missed the accept line was left blank, so a shelf where 59% of in-catalog facings were shown read as broken. Show the best candidate immediately as `provisional`, keep the track re-classifiable so a later attempt upgrades or corrects it, and never blank a label already on screen. - identity.py / identity_lanes.py: every decision now carries `provisional_sku` - the row the evidence supports well enough to show, separate from `best_sku`, which still means "cleared the gate". The accept line does not move: far-unknown false accepts stay at 0.066%. - live.py: a below-gate candidate labels the track with `identity_level: provisional` and `confirmed = False`, so the planner and the crop lane keep offering it. Accepted labels stay frozen. Inherited provisional labels stay re-classifiable. - Clients render provisional distinctly (amber chip, dashed box) via `LiveScanOverlayPolicy.isProvisional(identity)`, which now reads the wire level instead of only the state. - build_stock_all.py merges stock, stock-lopia, stock-mediya and stock-milk150 into stock-all (111 images, 60 SKUs). The server could only read one folder, so anything outside it came back blank. stock-demo is byte-identical to mediya+milk150 and is not a source. Measured on add/image6.jpg at live resolution: in-catalog facings shown 59.4% -> 86.0%, correct shown 38 -> 55, precision 97.4% -> 87.3%. On the wire at 8fps 0.5x: 65 of 79 tracks labelled, 742 accepted + 309 provisional observations, first label 1.27s. docs/research/2026-09-29-shelf-intelligence/ carries the experiments and the decision: group signals (combined AUC 0.991 sibling / 0.990 unrelated), OCR scheduling (group representatives cut calls 4.4x and wall 3.3x), and the finding that group propagation damages more labels than it rescues on this shelf, so groups schedule compute rather than overwrite labels. 331 tests pass.
318 lines
11 KiB
Python
318 lines
11 KiB
Python
"""Identity ranking for catalog scores.
|
|
|
|
Separates raw matcher scores from accept/unknown/ambiguous/reject so a
|
|
zero-score crop cannot become a SKU. Three score scales are recognized:
|
|
|
|
- `inlier`: Van's composite, kept as the selectable baseline lane (E-1).
|
|
- `local`: `identity_lanes.LocalLane`'s margin-over-runner-up, in inlier units.
|
|
- `cosine`: the embedding gallery, fenced off and not in the live path.
|
|
|
|
The `local` line is measured on the corrected image6 labels: 2.0 keeps 95% of
|
|
shown labels correct at 59% coverage with the text lane, 1.0 keeps 87% at 66%.
|
|
It is tuned on one photo, so it is a direction, not a calibration; E-4 replaces
|
|
it once Tier-2 held-out labels exist.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
|
|
IDENTITY_ACCEPTED = "accepted"
|
|
IDENTITY_AMBIGUOUS = "ambiguous"
|
|
IDENTITY_UNKNOWN = "unknown"
|
|
IDENTITY_REJECTED = "rejected"
|
|
|
|
# ponytail: closed-set defaults for the baseline inlier scale. Recalibrate when
|
|
# held-out scores exist.
|
|
DEFAULT_MIN_SCORE = 1.0
|
|
DEFAULT_MIN_MARGIN = 0.15
|
|
DEFAULT_MIN_CONFIDENCE = 0.55
|
|
|
|
# `local` scale (identity_lanes.LocalLane). The value is already a margin over
|
|
# the runner-up, so the relative-margin test does not apply to it: a single
|
|
# absolute line is the whole gate. Kept equal to identity_lanes.LOCAL_ACCEPT,
|
|
# which is what the engine's DecisionPolicy uses; this copy exists for the
|
|
# evaluator's `--no-text` path, and two different lines would score the same
|
|
# crops two ways.
|
|
LOCAL_ACCEPT = 4.0
|
|
|
|
|
|
def relative_confidence(top_score: float, runner_up_score: float) -> float:
|
|
"""Existing matcher formula: top / (top + 0.5 * runner)."""
|
|
return round(min(1.0, top_score / max(1.0, (top_score + runner_up_score * 0.5))), 4)
|
|
|
|
|
|
# Cosine scale (gallery). Same-product pairs were measured above 0.6 and
|
|
# different products below 0.5. These are not a new calibration.
|
|
COSINE_ACCEPT = 0.60
|
|
COSINE_AMBIGUOUS = 0.50
|
|
COSINE_MARGIN = 0.04
|
|
VOTE_CAP = 3
|
|
|
|
|
|
def _finite(value: Any) -> Optional[float]:
|
|
try:
|
|
number = float(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
if number != number or number in (float("inf"), float("-inf")):
|
|
return None
|
|
return number
|
|
|
|
|
|
def aggregate_votes(
|
|
observations: Sequence[Dict[str, float]],
|
|
*,
|
|
cap: int = VOTE_CAP,
|
|
) -> Dict[str, float]:
|
|
"""Mean of the newest observations, at most `cap`.
|
|
|
|
Older frames are dropped, not averaged in. An empty observation still
|
|
counts, so a later miss can outvote an earlier weak hit. Inlier and
|
|
cosine scores must not be mixed in one list.
|
|
"""
|
|
window = list(observations)[-max(1, int(cap)):]
|
|
if not window:
|
|
return {}
|
|
totals: Dict[str, float] = {}
|
|
for scores in window:
|
|
for sku_id, score in (scores or {}).items():
|
|
number = _finite(score)
|
|
if number is None:
|
|
continue
|
|
totals[str(sku_id)] = totals.get(str(sku_id), 0.0) + number
|
|
count = len(window)
|
|
return {sku_id: total / count for sku_id, total in totals.items()}
|
|
|
|
|
|
def _cosine_decision(
|
|
sorted_candidates: List[Tuple[str, float]],
|
|
*,
|
|
method: str,
|
|
details: Optional[Dict[str, Any]],
|
|
accept: float,
|
|
ambiguous: float,
|
|
margin_min: float,
|
|
) -> Dict[str, Any]:
|
|
top_sku, top_score = sorted_candidates[0]
|
|
runner_score = sorted_candidates[1][1] if len(sorted_candidates) > 1 else 0.0
|
|
margin = top_score - runner_score
|
|
if top_score >= accept and margin >= margin_min:
|
|
level = IDENTITY_ACCEPTED
|
|
elif top_score >= ambiguous:
|
|
level = IDENTITY_AMBIGUOUS
|
|
else:
|
|
level = IDENTITY_REJECTED
|
|
return {
|
|
"status": level,
|
|
"method": method,
|
|
"best_sku": top_sku if level == IDENTITY_ACCEPTED else None,
|
|
"provisional_sku": top_sku,
|
|
"score": float(top_score),
|
|
"confidence": float(top_score),
|
|
"identity_level": level,
|
|
"ranked_candidates": [
|
|
{"sku_id": sku, "score": float(value)} for sku, value in sorted_candidates[:3]
|
|
],
|
|
"details": details or {},
|
|
"message": None,
|
|
}
|
|
|
|
|
|
def decision_for_match(
|
|
match: Any,
|
|
*,
|
|
method: str = "gallery",
|
|
margin: float = COSINE_MARGIN,
|
|
) -> Dict[str, Any]:
|
|
"""Rank one gallery match. Rejection is the gallery's, not a second threshold.
|
|
|
|
`match()` already applied the raw 0.55 line or the head's 0.40 line. Re-ranking
|
|
those scores against COSINE_ACCEPT (0.60) would refuse a head match the gallery
|
|
accepted. The only extra gate here is the published cosine margin.
|
|
"""
|
|
similarity = _finite(getattr(match, "similarity", 0.0)) or 0.0
|
|
if match is None or getattr(match, "rejected", False) or not getattr(match, "sku_id", None):
|
|
return {
|
|
"status": IDENTITY_REJECTED,
|
|
"method": method,
|
|
"best_sku": None,
|
|
"score": similarity,
|
|
"confidence": similarity,
|
|
"identity_level": IDENTITY_REJECTED,
|
|
"ranked_candidates": [],
|
|
"details": {},
|
|
"message": "no enrolled product is close enough",
|
|
}
|
|
runner = getattr(match, "runner_up", None)
|
|
runner_score = _finite(getattr(match, "runner_up_similarity", 0.0)) or 0.0
|
|
gap = similarity - runner_score
|
|
close = bool(runner) and gap < margin
|
|
level = IDENTITY_AMBIGUOUS if close else IDENTITY_ACCEPTED
|
|
ranked = [{"sku_id": str(match.sku_id), "score": similarity}]
|
|
if runner:
|
|
ranked.append({"sku_id": str(runner), "score": runner_score})
|
|
return {
|
|
"status": level,
|
|
"method": method,
|
|
"best_sku": None if close else str(match.sku_id),
|
|
"score": similarity,
|
|
"confidence": similarity,
|
|
"identity_level": level,
|
|
"ranked_candidates": ranked,
|
|
"details": {},
|
|
"message": None,
|
|
}
|
|
|
|
|
|
def decide_identity(
|
|
scores: Dict[str, float],
|
|
*,
|
|
min_score: float = DEFAULT_MIN_SCORE,
|
|
min_margin: float = DEFAULT_MIN_MARGIN,
|
|
min_confidence: float = DEFAULT_MIN_CONFIDENCE,
|
|
details: Optional[Dict[str, Any]] = None,
|
|
method: str = "hybrid",
|
|
scale: str = "inlier",
|
|
) -> Dict[str, Any]:
|
|
"""Rank catalog scores into accepted / ambiguous / unknown / rejected.
|
|
|
|
Zero or missing evidence is rejected. A weak but unique top score is
|
|
unknown. Two close scores are ambiguous. Only a unique, above-threshold
|
|
score is accepted.
|
|
"""
|
|
if scale == "cosine":
|
|
if min_score == DEFAULT_MIN_SCORE:
|
|
min_score = COSINE_ACCEPT
|
|
if min_margin == DEFAULT_MIN_MARGIN:
|
|
min_margin = COSINE_MARGIN
|
|
if min_confidence == DEFAULT_MIN_CONFIDENCE:
|
|
min_confidence = COSINE_AMBIGUOUS
|
|
elif scale == "local" and min_score == DEFAULT_MIN_SCORE:
|
|
min_score = LOCAL_ACCEPT
|
|
cleaned: Dict[str, float] = {}
|
|
for sku_id, score in (scores or {}).items():
|
|
number = _finite(score)
|
|
if number is None:
|
|
continue
|
|
cleaned[str(sku_id)] = number
|
|
if not cleaned:
|
|
return {
|
|
"status": IDENTITY_REJECTED,
|
|
"method": method,
|
|
"best_sku": None,
|
|
"provisional_sku": None,
|
|
"score": 0.0,
|
|
"confidence": 0.0,
|
|
"identity_level": IDENTITY_REJECTED,
|
|
"ranked_candidates": [],
|
|
"details": {},
|
|
"message": "No templates available",
|
|
}
|
|
|
|
sorted_candidates: List[Tuple[str, float]] = sorted(
|
|
cleaned.items(), key=lambda item: item[1], reverse=True
|
|
)
|
|
if scale == "cosine":
|
|
return _cosine_decision(
|
|
sorted_candidates,
|
|
method=method,
|
|
details=details,
|
|
accept=min_score,
|
|
ambiguous=min_confidence,
|
|
margin_min=min_margin,
|
|
)
|
|
top_sku, top_score = sorted_candidates[0]
|
|
runner_up_sku, runner_up_score = (
|
|
sorted_candidates[1] if len(sorted_candidates) > 1 else (None, 0.0)
|
|
)
|
|
confidence = relative_confidence(top_score, runner_up_score)
|
|
ranked = [{"sku_id": s, "score": round(sc, 2)} for s, sc in sorted_candidates[:5]]
|
|
top_details = (details or {}).get(top_sku, {})
|
|
|
|
if scale == "local":
|
|
# The score is already a margin over the runner-up, so the relative
|
|
# margin test below would apply the same correction twice. One absolute
|
|
# line is the whole gate; a candidate under it is unknown, not ambiguous,
|
|
# because the lane has already said which one it prefers.
|
|
if top_score <= 0.0:
|
|
level, message, best = IDENTITY_REJECTED, "No matching evidence", None
|
|
elif top_score < min_score:
|
|
level, message, best = IDENTITY_UNKNOWN, "Top score below accept threshold", None
|
|
else:
|
|
level, message, best = IDENTITY_ACCEPTED, "Accepted unique catalog match", top_sku
|
|
return {
|
|
"status": "success" if level == IDENTITY_ACCEPTED else level,
|
|
"method": method,
|
|
"best_sku": best,
|
|
"provisional_sku": top_sku if top_score > 0.0 else None,
|
|
"score": round(top_score, 2),
|
|
"confidence": round(top_score, 4),
|
|
"identity_level": level,
|
|
"ranked_candidates": ranked,
|
|
"details": top_details,
|
|
"message": message,
|
|
}
|
|
|
|
if scale == "van":
|
|
# Van's contract: the top SKU, always answered. No gate, no abstention -
|
|
# this is the baseline the decision is measured against, and it exists
|
|
# so the stream comparison is reproducible rather than asserted.
|
|
if top_score <= 0.0:
|
|
level, message, best = IDENTITY_REJECTED, "No matching evidence", None
|
|
else:
|
|
level, message, best = IDENTITY_ACCEPTED, "Always-argmax baseline", top_sku
|
|
return {
|
|
"status": "success" if level == IDENTITY_ACCEPTED else level,
|
|
"method": method,
|
|
"best_sku": best,
|
|
"provisional_sku": top_sku if top_score > 0.0 else None,
|
|
"score": round(top_score, 2),
|
|
"confidence": confidence,
|
|
"identity_level": level,
|
|
"ranked_candidates": ranked,
|
|
"details": top_details,
|
|
"message": message,
|
|
}
|
|
|
|
if top_score <= 0.0:
|
|
level = IDENTITY_REJECTED
|
|
message = "No matching evidence"
|
|
best = None
|
|
elif (
|
|
runner_up_sku is not None
|
|
and top_score > 0.0
|
|
and (top_score - runner_up_score) < min_margin * max(top_score, 1.0)
|
|
):
|
|
level = IDENTITY_AMBIGUOUS
|
|
message = f"Close scores: {top_sku} vs {runner_up_sku}"
|
|
best = None
|
|
elif top_score < min_score or confidence < min_confidence:
|
|
level = IDENTITY_UNKNOWN
|
|
message = "Top score below accept threshold"
|
|
best = None
|
|
else:
|
|
level = IDENTITY_ACCEPTED
|
|
message = "Accepted unique catalog match"
|
|
best = top_sku
|
|
|
|
return {
|
|
"status": "success" if level == IDENTITY_ACCEPTED else level,
|
|
"method": method,
|
|
"best_sku": best,
|
|
"provisional_sku": top_sku if top_score > 0.0 else None,
|
|
"score": round(top_score, 2),
|
|
"confidence": confidence,
|
|
"identity_level": level,
|
|
"ranked_candidates": ranked,
|
|
"details": top_details,
|
|
"message": message,
|
|
}
|
|
|
|
|
|
def identity_from_ranked(
|
|
ranked_candidates: Sequence[Dict[str, Any]],
|
|
**kwargs: Any,
|
|
) -> Dict[str, Any]:
|
|
scores = {str(row["sku_id"]): float(row["score"]) for row in ranked_candidates}
|
|
return decide_identity(scores, **kwargs)
|