feat: recognition server API, reference stream client, and live label memory
app.py becomes the recognition server: /v1 REST (health, catalog + template images, samples, recognize, classify, inspect, stock) plus /v1/stream over WebSocket, with token auth, size limits, one consistent error shape, and the legacy /api/sessions routes kept but now backed by the real engine instead of the fake adapter. Default bind is 127.0.0.1; LAN and TLS are opt-in. stream_client.py is the reference non-browser client (camera, video file, or a pan across a still) and prints results/s, RTT, detect time and label stability. Live quality work driven by measurements: - Labels are remembered per place: a fresh track covering a recently recognized product inherits the label instead of queueing for classification again. On a continuous pan this raised recognized products in view from 9/66 to 17-21/66 and cut time-to-first-label to ~1.6 s. - Classification priority is newest-first, because sorting by area spends the budget on products that are already leaving the frame. - Per-session classification concurrency raised to 12. hybrid_fast (ORB shortlist then hybrid) is implemented but NOT the default: it is 3.4x faster (13 ms vs 44 ms p50) yet agreed with hybrid on only 47% of accepted crops across 553 crops, far below the 98% adoption rule. It stays available for experiments; the numbers are in the commit and the benchmark. Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
afb59b487f
commit
5f84f4f2a7
5 changed files with 1152 additions and 1170 deletions
|
|
@ -288,6 +288,76 @@ class ProductFeatureMatcher:
|
|||
|
||||
return scores, details
|
||||
|
||||
def score_crop_fast(
|
||||
self,
|
||||
crop_bgr: np.ndarray,
|
||||
shortlist: int = 5
|
||||
) -> Tuple[Dict[str, float], Dict[str, Dict[str, Any]]]:
|
||||
"""
|
||||
ORB-shortlist then hybrid-score the top candidates only.
|
||||
|
||||
Same scoring formula as 'hybrid', but the expensive SURF/ORB/BRISK
|
||||
descriptor matching runs on `shortlist` templates instead of all of them.
|
||||
Every other template scores 0.0, so decide_identity() still ranks the
|
||||
whole catalog. This is a live-mode speed path; validate it against
|
||||
'hybrid' with bench_engine.py --compare before trusting it.
|
||||
"""
|
||||
if crop_bgr is None or crop_bgr.size == 0:
|
||||
return {}, {}
|
||||
crop_gray = cv2.cvtColor(crop_bgr, cv2.COLOR_BGR2GRAY)
|
||||
extractors = self.thread_extractors()
|
||||
|
||||
orb = extractors["orb"]
|
||||
kp_orb, des_orb = orb.detect_and_compute(crop_gray)
|
||||
orb_scores: Dict[str, float] = {}
|
||||
for sku_id, tmpl in self.db.templates.items():
|
||||
kp_t = tmpl["features"]["orb"]["keypoints"]
|
||||
des_t = tmpl["features"]["orb"]["descriptors"]
|
||||
matches = self.match_descriptors(des_orb, des_t, orb.norm_type, ratio_thresh=0.75)
|
||||
inliers, _, _ = self.compute_geometric_inliers(kp_orb, kp_t, matches)
|
||||
denom = max(1, min(len(kp_orb), len(kp_t)))
|
||||
orb_scores[sku_id] = inliers * 1.5 + (inliers / denom) * 80.0
|
||||
|
||||
candidates = sorted(orb_scores.items(), key=lambda kv: kv[1], reverse=True)[:shortlist]
|
||||
|
||||
scores: Dict[str, float] = {sku_id: 0.0 for sku_id in self.db.templates}
|
||||
details: Dict[str, Dict[str, Any]] = {}
|
||||
sub_methods = ["surf", "orb", "brisk"]
|
||||
weights = {"surf": 0.45, "orb": 0.25, "brisk": 0.30}
|
||||
query_features = {}
|
||||
for sm in sub_methods:
|
||||
kp_q, des_q = extractors[sm].detect_and_compute(crop_gray)
|
||||
query_features[sm] = (kp_q, des_q)
|
||||
|
||||
for sku_id, _ in candidates:
|
||||
tmpl = self.db.templates[sku_id]
|
||||
composite = 0.0
|
||||
sku_sub_details = {}
|
||||
for sm in sub_methods:
|
||||
kp_q, des_q = query_features[sm]
|
||||
kp_t = tmpl["features"][sm]["keypoints"]
|
||||
des_t = tmpl["features"][sm]["descriptors"]
|
||||
matches = self.match_descriptors(des_q, des_t, extractors[sm].norm_type, ratio_thresh=0.75)
|
||||
inliers, _, _ = self.compute_geometric_inliers(kp_q, kp_t, matches)
|
||||
denom = max(1, min(len(kp_q), len(kp_t)))
|
||||
sub_score = inliers * 1.5 + (inliers / denom) * 80.0
|
||||
composite += weights[sm] * sub_score
|
||||
sku_sub_details[sm] = {
|
||||
"inliers": inliers,
|
||||
"matches": len(matches),
|
||||
"kp_q_count": len(kp_q),
|
||||
"kp_t_count": len(kp_t),
|
||||
"sub_score": round(sub_score, 2),
|
||||
}
|
||||
scores[sku_id] = composite
|
||||
details[sku_id] = {
|
||||
"score": composite,
|
||||
"sub_details": sku_sub_details,
|
||||
"kp_query": query_features["surf"][0],
|
||||
"kp_tmpl": tmpl["features"]["surf"]["keypoints"],
|
||||
}
|
||||
return scores, details
|
||||
|
||||
def classify_crop(
|
||||
self,
|
||||
crop_bgr: np.ndarray,
|
||||
|
|
@ -295,16 +365,19 @@ class ProductFeatureMatcher:
|
|||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Classifies a product crop against all SKU templates.
|
||||
Methods supported: 'surf', 'orb', 'brisk', 'hybrid'.
|
||||
Methods supported: 'surf', 'orb', 'brisk', 'hybrid', 'hybrid_fast'.
|
||||
"""
|
||||
if crop_bgr is None or crop_bgr.size == 0:
|
||||
return {"status": "error", "message": "Empty crop image"}
|
||||
|
||||
method = method.lower()
|
||||
if method not in ["surf", "orb", "brisk", "hybrid"]:
|
||||
if method == "hybrid_fast":
|
||||
scores, details = self.score_crop_fast(crop_bgr)
|
||||
elif method in ["surf", "orb", "brisk", "hybrid"]:
|
||||
scores, details = self.score_crop(crop_bgr, method=method)
|
||||
else:
|
||||
return {"status": "error", "message": f"Unknown method: {method}"}
|
||||
|
||||
scores, details = self.score_crop(crop_bgr, method=method)
|
||||
ranked = decide_identity(scores, details=details, method=method)
|
||||
return ranked
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue