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

@ -47,7 +47,7 @@ STATE_RECOGNIZED = "recognized"
STATE_NEEDS_CONFIRMATION = "needs_confirmation"
STATE_UNRECOGNIZED = "unrecognized"
CLASSIFY_METHODS = ("surf", "orb", "brisk", "hybrid")
CLASSIFY_METHODS = ("surf", "orb", "brisk", "hybrid", "hybrid_fast")
class UndecodableImage(ValueError):
@ -71,6 +71,12 @@ class EngineSettings:
# p50 while MPS takes ~112 ms with identical boxes, so CPU is the default.
# Override with --device / DORAYAKI_DEVICE (cpu | mps | cuda | cuda:0).
device: Optional[str] = "cpu"
# Live-mode defaults, measured with stream_client.py --pace result:
# hybrid classifies at ~44 ms/crop p50 and is the most accurate method
# (21/21 SKUs on image.jpg). hybrid_fast (ORB shortlist) is 3.4x faster but
# agreed with hybrid on only 47% of accepted crops, so it is NOT the default
# and stays available for experiments only (bench_engine.py --compare).
live_inflight: int = 12
imgsz: int = 640
classify_workers: int = 0 # 0 -> max(2, cpu_count - 2)
live_method: str = "hybrid"
@ -348,7 +354,10 @@ class RecognitionEngine:
method = (method or self.settings.live_method).lower()
if method not in CLASSIFY_METHODS:
raise UnknownMethod(method)
scores, _ = self.matcher.score_crop(crop, method=method)
if method == "hybrid_fast":
scores, _ = self.matcher.score_crop_fast(crop)
else:
scores, _ = self.matcher.score_crop(crop, method=method)
return {str(sku): float(score) for sku, score in scores.items()}
def classify_boxes(
@ -367,7 +376,10 @@ class RecognitionEngine:
return []
ordered = sorted(boxes, key=lambda b: b["width"] * b["height"], reverse=True)
crops = [self.crop(image, box["box"]) for box in ordered]
futures = [self._executor.submit(self.matcher.score_crop, crop, method) for crop in crops]
if method == "hybrid_fast":
futures = [self._executor.submit(self.matcher.score_crop_fast, crop) for crop in crops]
else:
futures = [self._executor.submit(self.matcher.score_crop, crop, method) for crop in crops]
results: List[Dict[str, Any]] = []
for box, future in zip(ordered, futures):
try:
@ -392,7 +404,10 @@ class RecognitionEngine:
if method not in CLASSIFY_METHODS:
raise UnknownMethod(method)
crop = self.crop(image, box)
scores, details = self.matcher.score_crop(crop, method=method)
if method == "hybrid_fast":
scores, details = self.matcher.score_crop_fast(crop)
else:
scores, details = self.matcher.score_crop(crop, method=method)
decision = decide_identity(scores, details=details, method=method)
identity = identity_payload(decision)
@ -416,7 +431,7 @@ class RecognitionEngine:
if visualized_sku:
try:
visualization = self.matcher.generate_match_visualization(
crop, visualized_sku, method=method
crop, visualized_sku, method=method if method != "hybrid_fast" else "hybrid"
)
except Exception:
visualization = None
@ -647,11 +662,12 @@ def settings_from_env(env: Optional[Dict[str, str]] = None) -> EngineSettings:
base,
weights_path=env.get("DORAYAKI_WEIGHTS", base.weights_path),
stock_dir=env.get("DORAYAKI_STOCK_DIR", base.stock_dir),
device=env.get("DORAYAKI_DEVICE") or None,
device=env.get("DORAYAKI_DEVICE") or base.device,
imgsz=int(env.get("DORAYAKI_IMGSZ", base.imgsz)),
classify_workers=int(env.get("DORAYAKI_CLASSIFY_WORKERS", base.classify_workers)),
live_method=env.get("DORAYAKI_LIVE_METHOD", base.live_method),
live_conf=float(env.get("DORAYAKI_LIVE_CONF", base.live_conf)),
live_inflight=int(env.get("DORAYAKI_LIVE_INFLIGHT", base.live_inflight)),
detect_conf=float(env.get("DORAYAKI_DETECT_CONF", base.detect_conf)),
)