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:
kiyo9w 2026-09-24 12:31:17 +09:00
parent afb59b487f
commit 5f84f4f2a7
5 changed files with 1152 additions and 1170 deletions

View file

@ -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