diff --git a/recognization/code/app.py b/recognization/code/app.py
index b01c90d..e773046 100644
--- a/recognization/code/app.py
+++ b/recognization/code/app.py
@@ -1,24 +1,38 @@
"""
-Interactive Web Application: Dorayaki (どら焼き)
-Supermarket Shelf Product Recognition & Inventory System
-Features:
-- Light, fresh pastel green visual design
-- Brand Logo: Dorayaki
-- Live camera photo capture + File upload + Select from D:\\recognization\\add
-- Clean bounding box detection (NO class labels, NO confidence text)
-- Dual language support (English / Japanese)
-- Fine-grained classification: SURF, ORB, BRISK, Hybrid (Paper method)
+Dorayaki recognition server (どら焼き).
+
+The computer-vision model runs here, as a service. Capture clients talk to it
+over a versioned REST API (stills, catalog, inspect) and a WebSocket stream
+(live frames in, tracked products and labels out). Any number of clients can
+connect at once; no client shares state with another.
+
+Run:
+ python code/app.py --port 8080
+ python code/app.py --host 0.0.0.0 --port 8080 # LAN, set DORAYAKI_API_TOKEN
+ python code/app.py --ssl-certfile cert.pem --ssl-keyfile key.pem
+
+The web client is served from / and is a pure client of the API; it can also be
+pointed at another server with ?server=https://host:port.
"""
+from __future__ import annotations
+
+import argparse
+import asyncio
+import base64
+import hmac
+import json
+import logging
import os
import sys
-import io
-import cv2
-import json
-import base64
-import argparse
-import numpy as np
-from typing import Optional, List, Dict
+import time
+import uuid
+from dataclasses import dataclass
+from typing import Any, Dict, List, Optional
+
+# Keep the demo off the network before anything imports ultralytics.
+os.environ.setdefault("YOLO_OFFLINE", "1")
+os.environ.setdefault("YOLO_AUTOINSTALL", "false")
current_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(current_dir)
@@ -28,1218 +42,750 @@ if os.path.exists(os.path.join(project_root, "stock")):
if current_dir not in sys.path:
sys.path.insert(0, current_dir)
-from pipeline import ShelfRecognitionPipeline
+from fastapi import ( # noqa: E402
+ Depends,
+ FastAPI,
+ Header,
+ HTTPException,
+ Request,
+ WebSocket,
+ WebSocketDisconnect,
+)
+from fastapi.middleware.cors import CORSMiddleware # noqa: E402
+from fastapi.responses import FileResponse, JSONResponse, Response # noqa: E402
+from fastapi.staticfiles import StaticFiles # noqa: E402
+from pydantic import BaseModel, Field # noqa: E402
+
+from engine import ( # noqa: E402
+ CLASSIFY_METHODS,
+ COORDINATE_CONVENTION,
+ EngineSettings,
+ ImageExpired,
+ RecognitionEngine,
+ STATE_DETECTED,
+ UndecodableImage,
+ UnknownMethod,
+ build_default_engine,
+ identity_payload,
+ settings_from_env,
+)
+from live import LiveSession, LiveSettings # noqa: E402
+
+LOGGER = logging.getLogger("dorayaki")
+
+MAX_UPLOAD_BYTES = 20 * 1024 * 1024
+MAX_FRAME_BYTES = 4 * 1024 * 1024
+DEFAULT_MAX_SESSIONS = 4
+WS_IDLE_TIMEOUT_S = 120.0
+CATALOG_VERSION_FALLBACK = "unknown"
-def encode_image_base64(img_bgr: np.ndarray, quality: int = 85) -> str:
- """Encodes an OpenCV image to a base64 JPEG data URL."""
- if img_bgr is None or img_bgr.size == 0:
- return ""
- success, buffer = cv2.imencode(".jpg", img_bgr, [cv2.IMWRITE_JPEG_QUALITY, quality])
- if not success:
- return ""
- return f"data:image/jpeg;base64,{base64.b64encode(buffer).decode('utf-8')}"
+# ---------------------------------------------------------------------------
+# settings
+# ---------------------------------------------------------------------------
-def decode_image_base64(data_url: str) -> Optional[np.ndarray]:
- """Decodes a base64 data URL into an OpenCV BGR image."""
- try:
- if "," in data_url:
- data_url = data_url.split(",")[1]
- img_bytes = base64.b64decode(data_url)
- np_arr = np.frombuffer(img_bytes, np.uint8)
- return cv2.imdecode(np_arr, cv2.IMREAD_COLOR)
- except Exception as e:
- print(f"[Error] Failed to decode base64 image: {e}")
- return None
+@dataclass
+class ServerSettings:
+ host: str = "127.0.0.1"
+ port: int = 8080
+ token: Optional[str] = None
+ cors_origins: str = "*"
+ max_sessions: int = DEFAULT_MAX_SESSIONS
+ live_method: str = "hybrid"
+ live_conf: float = 0.1
+ log_level: str = "info"
+ ssl_certfile: Optional[str] = None
+ ssl_keyfile: Optional[str] = None
+
+
+def server_settings_from_env() -> ServerSettings:
+ base = ServerSettings()
+ return ServerSettings(
+ host=os.environ.get("DORAYAKI_HOST", base.host),
+ port=int(os.environ.get("DORAYAKI_PORT", base.port)),
+ token=os.environ.get("DORAYAKI_API_TOKEN") or None,
+ cors_origins=os.environ.get("DORAYAKI_CORS_ORIGINS", base.cors_origins),
+ max_sessions=int(os.environ.get("DORAYAKI_MAX_SESSIONS", base.max_sessions)),
+ live_method=os.environ.get("DORAYAKI_LIVE_METHOD", base.live_method),
+ live_conf=float(os.environ.get("DORAYAKI_LIVE_CONF", base.live_conf)),
+ log_level=os.environ.get("DORAYAKI_LOG_LEVEL", base.log_level),
+ ssl_certfile=os.environ.get("DORAYAKI_SSL_CERTFILE") or None,
+ ssl_keyfile=os.environ.get("DORAYAKI_SSL_KEYFILE") or None,
+ )
+
+
+# ---------------------------------------------------------------------------
+# request models
+# ---------------------------------------------------------------------------
+
+
+class StockUpdateBody(BaseModel):
+ delta: int = Field(..., ge=-100, le=100, description="units added (+) or dispensed (-)")
+
+
+class ClassifyBody(BaseModel):
+ method: Optional[str] = None
+ detection_ids: Optional[List[int]] = None
+
+
+class InspectBody(BaseModel):
+ box: List[int] = Field(..., min_length=4, max_length=4)
+ method: Optional[str] = None
+
+
+# ---------------------------------------------------------------------------
+# helpers
+# ---------------------------------------------------------------------------
+
+
+def _error(code: str, message: str, status_code: int) -> HTTPException:
+ return HTTPException(status_code=status_code, detail={"error": {"code": code, "message": message}})
+
+
+def _require_method(method: Optional[str]) -> str:
+ chosen = (method or "hybrid").lower()
+ if chosen not in CLASSIFY_METHODS:
+ raise _error("BAD_REQUEST", f"unknown method '{method}'", 400)
+ return chosen
def get_available_shelf_images(add_dir: str = "add") -> List[Dict[str, str]]:
- """Lists images from D:\\recognization\\add and root image.jpg."""
- items = []
+ """Same demo set the old /api/shelf_samples returned, now id-keyed."""
+ items: List[Dict[str, str]] = []
if os.path.exists("image.jpg"):
- items.append({"path": "image.jpg", "label": "image.jpg (Main Shelf)"})
+ items.append({"id": "main_shelf", "path": "image.jpg", "label": "image.jpg (Main Shelf)"})
if os.path.exists(add_dir):
valid_exts = (".webp", ".jpg", ".jpeg", ".png")
files = sorted(
[f for f in os.listdir(add_dir) if f.lower().endswith(valid_exts)],
- key=lambda x: (len(x), x)
+ key=lambda x: (len(x), x),
)
- for f in files:
- p = os.path.join(add_dir, f).replace("\\", "/")
- items.append({"path": p, "label": f"{add_dir}/{f}"})
+ for name in files:
+ items.append(
+ {
+ "id": name.rsplit(".", 1)[0],
+ "path": os.path.join(add_dir, name).replace("\\", "/"),
+ "label": f"{add_dir}/{name}",
+ }
+ )
- # Also include runs samples if present
mosaic = "runs/detect/train/train_batch0.jpg"
if os.path.exists(mosaic):
- items.append({"path": mosaic, "label": "train_batch0.jpg (Mosaic)"})
-
+ items.append({"id": "train_mosaic", "path": mosaic, "label": "train_batch0.jpg (Mosaic)"})
return items
-def create_fastapi_app(pipeline: ShelfRecognitionPipeline):
- from fastapi import FastAPI, Request
- from fastapi.responses import HTMLResponse, JSONResponse
- from fastapi.middleware.cors import CORSMiddleware
+def build_legacy_scans(engine: RecognitionEngine):
+ """The /api/sessions* routes kept from the earlier session model, now backed
+ by the real engine instead of the fake adapter."""
+ from observe import PerceptionService, ScanService
+ from yolo_adapter import YoloMatcherAdapter
- app = FastAPI(title="Dorayaki - Supermarket Shelf Intelligence")
+ class _DetectorFacade:
+ def __init__(self, engine: RecognitionEngine):
+ self.engine = engine
+ self.weights_path = engine.settings.weights_path
+
+ def detect(self, image, conf_threshold: float = 0.25, iou_threshold: float = 0.45):
+ boxes = self.engine.detect(image, conf=conf_threshold)
+ return {"status": "success", "boxes": boxes, "total_detected": len(boxes)}
+
+ def detect_empty_slots(self, image_shape, boxes, min_gap_ratio: float = 0.75):
+ return self.engine.empty_slots(image_shape, boxes)
+
+ def crop_roi(self, image, box):
+ return self.engine.crop(image, box)
+
+ class _MatcherFacade:
+ def __init__(self, engine: RecognitionEngine):
+ self.engine = engine
+
+ def classify_crop(self, crop, method: str = "hybrid"):
+ scores = self.engine.score_crop(crop, method=method)
+ from identity import decide_identity
+
+ return decide_identity(scores, method=method)
+
+ adapter = YoloMatcherAdapter(_DetectorFacade(engine), _MatcherFacade(engine), method="hybrid")
+ return ScanService(PerceptionService(adapter))
+
+
+# ---------------------------------------------------------------------------
+# app factory
+# ---------------------------------------------------------------------------
+
+
+def create_app(
+ engine: RecognitionEngine,
+ settings: Optional[ServerSettings] = None,
+ *,
+ web_dir: Optional[str] = None,
+ scans: Any = None,
+) -> FastAPI:
+ settings = settings or server_settings_from_env()
+ app = FastAPI(
+ title="Dorayaki Recognition Server",
+ version="1.0",
+ description="Shelf product detection, tracking and classification for capture clients.",
+ )
app.add_middleware(
CORSMiddleware,
- allow_origins=["*"],
- allow_credentials=True,
+ allow_origins=[o.strip() for o in settings.cors_origins.split(",") if o.strip()] or ["*"],
+ allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
- HTML_PAGE = """
-
-
-
-
- Dorayaki - Smart Supermarket Shelf AI
-
-
-
-
-
-
-
-
-
-
- Dorayaki どら焼き
-
-
Smart Supermarket Shelf AI & Inventory Recognition
-
-
-
-
-
-
-
-
-
-
-
-
-
- 👆 Click on any product packaging to identify & view real-time inventory
-
-
-
-
-
-
-
-
-
-
🏷️
-
No product selected.
Click any merchandise item on the shelf image to inspect details.
-
-
-
-
-
![Thumbnail]()
-
-
SKU_001
-
Product Name
-
¥0
-
-
-
-
-
-
On Shelf (In Stock)
-
0
-
-
-
Sold / Dispatched
-
0
-
-
-
-
-
-
-
-
-
-
-
-
- Visual keypoint matching will appear here
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-"""
-
- @app.get("/", response_class=HTMLResponse)
- async def index():
- return HTMLResponse(content=HTML_PAGE)
-
- @app.get("/api/shelf_samples")
- async def get_shelf_samples():
- samples = get_available_shelf_images("add")
- return {"status": "success", "samples": samples}
-
- @app.post("/api/load_shelf")
- async def load_shelf(payload: dict):
- img_path = payload.get("image_path", "image.jpg")
- init_res = pipeline.set_active_shelf_image(img_path)
- if init_res.get("status") == "success":
- return {
- "status": "success",
- "boxes": init_res["boxes"],
- "empty_slots": init_res.get("empty_slots", []),
- "image_base64": encode_image_base64(pipeline.current_image)
- }
- return JSONResponse(status_code=400, content={"status": "error", "message": "Failed to load image"})
-
- @app.post("/api/upload_shelf")
- async def upload_shelf(payload: dict):
- data_url = payload.get("image_base64", "")
- img_bgr = decode_image_base64(data_url)
- if img_bgr is None:
- return JSONResponse(status_code=400, content={"status": "error", "message": "Failed to decode image"})
-
- init_res = pipeline.set_active_shelf_image(img_bgr)
- if init_res.get("status") == "success":
- return {
- "status": "success",
- "boxes": init_res["boxes"],
- "empty_slots": init_res.get("empty_slots", []),
- "image_base64": encode_image_base64(pipeline.current_image)
- }
- return JSONResponse(status_code=400, content={"status": "error", "message": "Detection failed"})
-
- @app.post("/api/click")
- async def handle_click(payload: dict):
- x = int(payload.get("x", 0))
- y = int(payload.get("y", 0))
- method = payload.get("method", "hybrid")
-
- res = pipeline.process_click(x, y, method=method)
- if res.get("status") == "success":
- return {
- "status": "success",
- "target_box": res["target_box"],
- "classification": res["classification"],
- "inventory": res["inventory"],
- "crop_base64": encode_image_base64(res["crop_image"]),
- "vis_base64": encode_image_base64(res["match_visualization"])
- }
- elif res.get("status") == "empty_slot":
- return {
- "status": "empty_slot",
- "empty_slot": res["empty_slot"],
- "message": res["message"]
- }
- return JSONResponse(status_code=404, content=res)
-
- @app.post("/api/update_stock")
- async def update_stock(payload: dict):
- sku_id = payload.get("sku_id")
- delta = int(payload.get("delta", -1))
- ok = pipeline.inventory.update_stock(sku_id, delta)
- if ok:
- p = pipeline.inventory.get_product(sku_id)
- return {"status": "success", "number_on": p["number_on"], "number_out": p["number_out"]}
- return JSONResponse(status_code=400, content={"status": "error"})
+ @app.on_event("shutdown")
+ async def _shutdown() -> None:
+ for session in list(sessions.values()):
+ session.close()
+ engine.shutdown()
return app
-def main():
- parser = argparse.ArgumentParser(description="Dorayaki - Smart Supermarket Shelf AI")
- parser.add_argument("--port", type=int, default=8080)
- parser.add_argument("--host", type=str, default="0.0.0.0")
+def create_fastapi_app(
+ engine: RecognitionEngine,
+ scans: Any = None,
+ settings: Optional[ServerSettings] = None,
+) -> FastAPI:
+ """Back-compat entry point; the server entry point is create_app()."""
+ return create_app(engine, settings, scans=scans)
+
+
+# ---------------------------------------------------------------------------
+# entry point
+# ---------------------------------------------------------------------------
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="Dorayaki - shelf recognition server")
+ parser.add_argument("--host", type=str, default=None)
+ parser.add_argument("--port", type=int, default=None)
+ parser.add_argument("--device", type=str, default=None, help="cpu | mps | cuda | cuda:0")
+ parser.add_argument("--imgsz", type=int, default=None)
+ parser.add_argument("--weights", type=str, default=None)
+ parser.add_argument("--stock-dir", type=str, default=None)
+ parser.add_argument("--classify-workers", type=int, default=None)
+ parser.add_argument("--live-method", type=str, default=None, choices=list(CLASSIFY_METHODS))
+ parser.add_argument("--live-conf", type=float, default=None)
+ parser.add_argument("--max-sessions", type=int, default=None)
+ parser.add_argument("--ssl-certfile", type=str, default=None)
+ parser.add_argument("--ssl-keyfile", type=str, default=None)
+ parser.add_argument("--log-level", type=str, default=None)
args = parser.parse_args()
- pipeline = ShelfRecognitionPipeline()
- app = create_fastapi_app(pipeline)
+ logging.basicConfig(
+ level=(args.log_level or server_settings_from_env().log_level or "info").upper(),
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
+ )
+
+ server = server_settings_from_env()
+ if args.host is not None:
+ server.host = args.host
+ if args.port is not None:
+ server.port = args.port
+ if args.max_sessions is not None:
+ server.max_sessions = args.max_sessions
+ if args.live_method is not None:
+ server.live_method = args.live_method
+ if args.live_conf is not None:
+ server.live_conf = args.live_conf
+ if args.ssl_certfile is not None:
+ server.ssl_certfile = args.ssl_certfile
+ if args.ssl_keyfile is not None:
+ server.ssl_keyfile = args.ssl_keyfile
+ if args.log_level is not None:
+ server.log_level = args.log_level
+
+ engine_settings = settings_from_env()
+ if args.device is not None:
+ engine_settings.device = args.device
+ if args.imgsz is not None:
+ engine_settings.imgsz = args.imgsz
+ if args.weights is not None:
+ engine_settings.weights_path = args.weights
+ if args.stock_dir is not None:
+ engine_settings.stock_dir = args.stock_dir
+ if args.classify_workers is not None:
+ engine_settings.classify_workers = args.classify_workers
+
+ LOGGER.info("Loading model and catalog (device=%s, imgsz=%s)...",
+ engine_settings.device or "auto", engine_settings.imgsz)
+ engine = build_default_engine(engine_settings)
+ warm_ms = engine.warm_up()
+ LOGGER.info(
+ "Ready: %d SKUs, catalog %s, warmup %.0f ms, classify workers %d",
+ engine.sku_count(), engine.catalog_version(), warm_ms, engine.classify_workers,
+ )
+
+ app = create_app(engine, server)
+
+ scheme = "https" if server.ssl_certfile else "http"
+ LOGGER.info("Dorayaki recognition server on %s://%s:%d (token %s)",
+ scheme, server.host, server.port, "required" if server.token else "off")
+ if server.host in ("127.0.0.1", "localhost"):
+ LOGGER.info("Open %s://127.0.0.1:%d in a browser for the web client", scheme, server.port)
+ else:
+ LOGGER.info("LAN mode: live camera needs https (secure context); use --ssl-certfile or a tunnel")
import uvicorn
- print(f"\n[Dorayaki Server] Starting on http://localhost:{args.port} (or http://127.0.0.1:{args.port})")
- uvicorn.run(app, host=args.host, port=args.port, log_level="info")
+
+ uvicorn.run(
+ app,
+ host=server.host,
+ port=server.port,
+ log_level=(server.log_level or "info").lower(),
+ access_log=False, # access log would print ?token=... query strings
+ ws="websockets-sansio",
+ ssl_certfile=server.ssl_certfile,
+ ssl_keyfile=server.ssl_keyfile,
+ )
if __name__ == "__main__":
diff --git a/recognization/code/engine.py b/recognization/code/engine.py
index 49657b5..cf279c2 100644
--- a/recognization/code/engine.py
+++ b/recognization/code/engine.py
@@ -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)),
)
diff --git a/recognization/code/feature_matcher.py b/recognization/code/feature_matcher.py
index 7abb325..b0171d7 100644
--- a/recognization/code/feature_matcher.py
+++ b/recognization/code/feature_matcher.py
@@ -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
diff --git a/recognization/code/live.py b/recognization/code/live.py
index 676661b..f38cee0 100644
--- a/recognization/code/live.py
+++ b/recognization/code/live.py
@@ -40,6 +40,12 @@ from tracking import SessionTracker # noqa: E402
EMPTY_SLOT_HISTORY = 5
EMPTY_SLOT_MIN_HITS = 3
EMPTY_SLOT_IOU = 0.3
+# Label memory: a recognized product keeps its label for any track that covers
+# the same place soon after, so panning does not re-classify one product many
+# times just because the tracker re-issued its id.
+LABEL_MEMORY_IOU = 0.55
+LABEL_MEMORY_FRAMES = 45
+LABEL_MEMORY_MAX = 200
STATS_WINDOW = 30
# A recognized label is only trustworthy while the box still covers the product
# it was measured on. Below this IoU against the box at label time, the track has
@@ -60,7 +66,7 @@ class LiveSettings:
max_attempts: int = 3
retry_gap: int = 8
area_growth: float = 1.3
- max_inflight_per_session: int = 6
+ max_inflight_per_session: int = 12
edge_margin_ratio: float = 0.015
glare_fraction: float = 0.02
glare_pixel: int = 245
@@ -170,6 +176,9 @@ class LiveSession:
self._slot_counter = 0
self._catalog_by_sku: Dict[str, Dict[str, Any]] = {}
self._catalog_version = ""
+ self._label_memory: "collections.deque[Dict[str, Any]]" = collections.deque(
+ maxlen=LABEL_MEMORY_MAX
+ )
self._last_quality: Dict[str, Any] = {}
self._created_at = time.time()
@@ -257,6 +266,7 @@ class LiveSession:
self._relabel_count.clear()
self._restart_count.clear()
self._slot_history.clear()
+ self._label_memory.clear()
self._slots = []
self._latest_image = None
self._latest_frame_id = None
@@ -368,6 +378,7 @@ class LiveSession:
self._slots = self._persist_slots(raw_slots)
quality = self._quality(image, self._tracks)
+ self._inherit_labels(seq)
# Collect the classification work under the lock; submit it after
# releasing it. add_done_callback can run the callback inline when the
@@ -400,6 +411,54 @@ class LiveSession:
}
return result
+ def _inherit_labels(self, seq: int) -> None:
+ """Give a fresh track the label of a recently recognized product it covers.
+
+ The same physical product can be tracked as several ids while the camera
+ pans. Remembering where a label was measured means each product is
+ classified once and its neighbours inherit the result instead of waiting
+ in the classification queue again.
+ """
+ if not self._label_memory:
+ return
+ for track in self._tracks.values():
+ if track.state != STATE_DETECTED or track.confirmed:
+ continue
+ for entry in reversed(self._label_memory):
+ if seq - entry["seq"] > LABEL_MEMORY_FRAMES:
+ break
+ if _iou(track.box, entry["box"]) >= LABEL_MEMORY_IOU:
+ track.state = STATE_RECOGNIZED
+ track.confirmed = True
+ track.label_box = list(track.box)
+ track.identity = {
+ "sku_id": entry["sku_id"],
+ "confidence": entry["confidence"],
+ "score": entry["score"],
+ "attempts": 0,
+ "inherited": True,
+ }
+ # An inherited label is a guess from the same place, not a
+ # measurement of this object: count it as re-verified so a
+ # later correction is auditable rather than a silent flip.
+ track.relabels = self._relabel_count.get(track.track_id, 0) + 1
+ self._relabel_count[track.track_id] = track.relabels
+ break
+
+ def _remember_label(self, track: TrackState, box: List[int], seq: int) -> None:
+ """Must be called with the session lock held."""
+ if track.state != STATE_RECOGNIZED or not track.identity:
+ return
+ self._label_memory.append(
+ {
+ "box": list(box),
+ "sku_id": track.identity.get("sku_id"),
+ "confidence": track.identity.get("confidence"),
+ "score": track.identity.get("score"),
+ "seq": seq,
+ }
+ )
+
def _drop_stale_label(self, track: TrackState) -> None:
"""Reset a settled label when the box has drifted off its product.
@@ -465,7 +524,10 @@ class LiveSession:
continue
eligible.append(track)
- eligible.sort(key=lambda track: track.area(), reverse=True)
+ # Newest first: while a camera pans, the products still worth labelling
+ # are the ones that just entered the frame. Sorting by area (the obvious
+ # default) spends the budget on products that are already leaving.
+ eligible.sort(key=lambda track: (track.first_seq, -track.area()))
planned: List[Tuple[TrackState, Any, int]] = []
for track in eligible[:capacity]:
crop = self.engine.crop(image, track.raw_box)
@@ -561,6 +623,7 @@ class LiveSession:
"score": decision.get("score"),
"attempts": track.attempts,
}
+ self._remember_label(track, submitted_box, self._seq)
elif track.attempts >= self.settings.max_attempts:
track.state = (
STATE_NEEDS_CONFIRMATION
@@ -612,6 +675,7 @@ class LiveSession:
"first_seen_seq": track.first_seq,
"relabels": track.relabels,
"restarts": track.restarts,
+ "inherited": bool(track.identity and track.identity.get("inherited")),
"identity": identity,
}
if product is not None:
diff --git a/recognization/code/stream_client.py b/recognization/code/stream_client.py
new file mode 100644
index 0000000..67d0dd6
--- /dev/null
+++ b/recognization/code/stream_client.py
@@ -0,0 +1,283 @@
+"""
+Reference live client for the Dorayaki recognition server.
+
+Streams frames over WebSocket /v1/stream and prints what the server sees. This
+is the documented example for capture sources that are not a browser: a cart
+camera, a wearable camera, a rail camera, a robot.
+
+Usage (from recognization/):
+ .venv/bin/python code/stream_client.py --source image.jpg --pan 12 --seconds 20
+ .venv/bin/python code/stream_client.py --source 0 # webcam index
+ .venv/bin/python code/stream_client.py --source clip.mp4 --seconds 30
+ .venv/bin/python code/stream_client.py --url ws://host:8080/v1/stream --token SECRET
+
+Frame protocol: 4-byte big-endian frame id + JPEG bytes.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import statistics
+import sys
+import time
+from typing import Any, Dict, List, Optional
+
+current_dir = os.path.dirname(os.path.abspath(__file__))
+project_root = os.path.dirname(current_dir)
+if os.path.exists(os.path.join(project_root, "stock")):
+ os.chdir(project_root)
+if current_dir not in sys.path:
+ sys.path.insert(0, current_dir)
+
+import cv2 # noqa: E402
+
+from websockets.sync.client import connect # noqa: E402
+
+
+def percentile(values: List[float], pct: float) -> float:
+ if not values:
+ return 0.0
+ ordered = sorted(values)
+ index = min(len(ordered) - 1, max(0, int(round((pct / 100.0) * (len(ordered) - 1)))))
+ return ordered[index]
+
+
+class FrameSource:
+ """Yields BGR frames from a camera, a video file, or a still image pan."""
+
+ def __init__(self, source: str, *, width: int, pan_step: int, pan_width: int = 1280):
+ self.source = source
+ self.width = width
+ self.pan_step = pan_step
+ self.pan_width = pan_width
+ self.capture: Optional[cv2.VideoCapture] = None
+ self.image = None
+ self.pan_x = 0
+ self.pan_direction = 1
+ if source.isdigit():
+ self.capture = cv2.VideoCapture(int(source))
+ elif os.path.isfile(source) and source.lower().endswith((".mp4", ".mov", ".avi", ".mkv", ".webm")):
+ self.capture = cv2.VideoCapture(source)
+ else:
+ self.image = cv2.imread(source)
+ if self.image is None:
+ raise SystemExit(f"cannot read source {source}")
+
+ def frame(self, index: int):
+ if self.image is not None:
+ height, width = self.image.shape[:2]
+ window_w = min(self.pan_width, width)
+ window_h = min(int(window_w * 9 / 16), height)
+ max_x = max(0, width - window_w)
+ x = min(self.pan_x, max_x)
+ y = max(0, (height - window_h) // 2)
+ window = self.image[y:y + window_h, x:x + window_w]
+ self.pan_x += self.pan_direction * self.pan_step
+ if self.pan_x >= max_x or self.pan_x <= 0:
+ self.pan_direction *= -1
+ return window
+ if self.capture is not None:
+ ok, frame = self.capture.read()
+ if not ok: # loop video files
+ self.capture.set(cv2.CAP_PROP_POS_FRAMES, 0)
+ ok, frame = self.capture.read()
+ if not ok:
+ return None
+ return frame
+ return None
+
+ def release(self) -> None:
+ if self.capture is not None:
+ self.capture.release()
+
+
+def encode(frame, width: int, quality: int) -> bytes:
+ height, current_width = frame.shape[:2]
+ if width and current_width > width:
+ scale = width / float(current_width)
+ frame = cv2.resize(frame, (int(width), int(round(height * scale))))
+ ok, buffer = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, quality])
+ if not ok:
+ raise RuntimeError("JPEG encode failed")
+ return buffer.tobytes()
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="Dorayaki live stream client")
+ parser.add_argument("--url", default="ws://127.0.0.1:8080/v1/stream")
+ parser.add_argument("--token", default=os.environ.get("DORAYAKI_API_TOKEN"))
+ parser.add_argument("--source", default="image.jpg", help="camera index, video path, or image path")
+ parser.add_argument("--pan", type=int, default=12, help="pixels per frame when panning a still image")
+ parser.add_argument("--seconds", type=float, default=20.0)
+ parser.add_argument("--width", type=int, default=1280, help="send width")
+ parser.add_argument("--quality", type=int, default=75)
+ parser.add_argument("--max-inflight", type=int, default=1)
+ parser.add_argument("--pace", choices=["send", "result"], default="send",
+ help="'send': keep the socket busy (frames drop server-side); "
+ "'result': send the next frame only after a result, so the "
+ "pan stays continuous (the mode to use for stability checks)")
+ parser.add_argument("--method", default=None, help="surf | orb | brisk | hybrid")
+ parser.add_argument("--json", action="store_true", help="print a JSON summary instead of the table")
+ parser.add_argument("--quiet", action="store_true", help="only print the final summary")
+ args = parser.parse_args()
+
+ url = args.url
+ if args.token and "token=" not in url:
+ url = f"{url}{'&' if '?' in url else '?'}token={args.token}"
+
+ source = FrameSource(args.source, width=args.width, pan_step=args.pan)
+ rtts: List[float] = []
+ detect_times: List[float] = []
+ frames_sent = 0
+ results = 0
+ drops = 0
+ first_label: Dict[int, float] = {}
+ label_latencies: List[float] = []
+ labels: Dict[int, str] = {}
+ label_changes = 0
+ unexplained_changes = 0
+ relabel_seen: Dict[int, int] = {}
+ restart_seen: Dict[int, int] = {}
+ inherited_seen: Dict[int, bool] = {}
+ seen_tracks: set = set()
+ last_summary: Dict[str, Any] = {}
+ started = time.perf_counter()
+ in_flight: Dict[int, float] = {}
+ pending_frame: Optional[int] = None
+
+ with connect(url, max_size=8 * 1024 * 1024) as socket:
+ hello = json.loads(socket.recv(timeout=10))
+ if hello.get("type") != "hello":
+ raise SystemExit(f"unexpected first message: {hello}")
+ if not args.quiet:
+ print(f"hello: session {hello['session_id']} model {hello['model']['version']} "
+ f"device {hello['model']['device']} config {hello['config']}")
+ if args.method:
+ socket.send(json.dumps({"type": "config", "method": args.method}))
+ ack = json.loads(socket.recv(timeout=10))
+ if not args.quiet:
+ print(f"config ack: {ack.get('config')}")
+
+ def consume(message: Dict[str, Any]) -> None:
+ """Fold one result into the running metrics."""
+ nonlocal results, last_summary, label_changes, unexplained_changes
+ if message.get("type") != "result":
+ if not args.quiet:
+ print(f"server message: {message}")
+ return
+ received_at = time.perf_counter()
+ sent_at = in_flight.pop(message["frame_id"], None)
+ if sent_at is not None:
+ rtts.append((received_at - sent_at) * 1000.0)
+ detect_times.append(message["timing_ms"]["detect"])
+ results += 1
+ last_summary = message["summary"]
+ now = time.perf_counter()
+ for track in message["tracks"]:
+ track_id = track["track_id"]
+ seen_tracks.add(track_id)
+ identity = track["identity"]
+ if identity["state"] != "recognized":
+ continue
+ sku = identity["sku_id"]
+ relabels = track.get("relabels", 0)
+ restarts = track.get("restarts", 0)
+ if restarts > restart_seen.get(track_id, 0):
+ # The id was restarted: earlier labels describe another object.
+ labels.pop(track_id, None)
+ first_label.pop(track_id, None)
+ inherited = bool(track.get("inherited"))
+ if track_id not in first_label:
+ first_label[track_id] = now
+ if not inherited:
+ label_latencies.append((now - started) * 1000.0)
+ elif labels.get(track_id) != sku:
+ # Only a measured-to-measured change on the same object is a
+ # label change; inherited labels describe the same product
+ # under a new track id.
+ if not inherited and not inherited_seen.get(track_id):
+ label_changes += 1
+ if relabels <= relabel_seen.get(track_id, 0):
+ unexplained_changes += 1
+ labels[track_id] = sku
+ inherited_seen[track_id] = inherited
+ relabel_seen[track_id] = relabels
+ restart_seen[track_id] = restarts
+ if not args.quiet and results % 10 == 0:
+ print(f"f{message['seq']:04d} tracks={len(message['tracks']):3d} "
+ f"recognized={last_summary['recognized']:3d} "
+ f"unconfirmed={last_summary.get('unconfirmed', 0):3d} "
+ f"detect={message['timing_ms']['detect']:5.1f}ms "
+ f"rtt={rtts[-1]:6.1f}ms fps={results / (now - started):5.2f}")
+
+ frame_id = 0
+ index = 0
+ while time.perf_counter() - started < args.seconds:
+ frame = source.frame(index)
+ if frame is None:
+ break
+ index += 1
+ frame_id = (frame_id + 1) & 0xFFFFFFFF
+ payload = encode(frame, args.width, args.quality)
+ if len(in_flight) >= args.max_inflight:
+ if pending_frame is not None:
+ drops += 1
+ pending_frame = frame_id
+ socket.send(frame_id.to_bytes(4, "big") + payload, text=False)
+ frames_sent += 1
+ else:
+ socket.send(frame_id.to_bytes(4, "big") + payload, text=False)
+ frames_sent += 1
+ in_flight[frame_id] = time.perf_counter()
+ if args.pace == "result":
+ # One frame in flight: the next frame is captured only after this
+ # one was answered, so the pan stays continuous.
+ while in_flight:
+ try:
+ consume(json.loads(socket.recv(timeout=5)))
+ except TimeoutError:
+ print("timeout waiting for a result")
+ break
+ continue
+
+ try:
+ consume(json.loads(socket.recv(timeout=5)))
+ except TimeoutError:
+ print("timeout waiting for a result")
+ break
+
+ source.release()
+ elapsed = time.perf_counter() - started
+ summary = {
+ "seconds": round(elapsed, 1),
+ "frames_sent": frames_sent,
+ "results": results,
+ "results_per_s": round(results / elapsed, 2) if elapsed else 0,
+ "frames_dropped_by_client": drops,
+ "rtt_ms_p50": round(statistics.median(rtts), 1) if rtts else None,
+ "rtt_ms_p95": round(percentile(rtts, 95), 1) if rtts else None,
+ "server_detect_ms_p50": round(statistics.median(detect_times), 1) if detect_times else None,
+ "server_detect_ms_p95": round(percentile(detect_times, 95), 1) if detect_times else None,
+ "tracks_seen": len(seen_tracks),
+ "tracks_labelled": len(labels),
+ "label_changes": label_changes,
+ "unexplained_label_changes": unexplained_changes,
+ "first_label_ms_p50": round(statistics.median(label_latencies), 0) if label_latencies else None,
+ "final_summary": last_summary,
+ }
+ if args.json:
+ print(json.dumps(summary, indent=2))
+ else:
+ print("\n--- stream summary ---")
+ for key, value in summary.items():
+ if key != "final_summary":
+ print(f"{key:24s} {value}")
+ print(f"{'final_summary':24s} {json.dumps(last_summary, ensure_ascii=False)}")
+ if results == 0:
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()