The external evaluation showed the matcher cannot say "unknown": on 20 shelves whose
products are absent it claimed 47.8% of facings as recognised, and its raw inlier
score cannot be thresholded (foreign shelves' median 6.0 overlaps the real shelf's
5.2 p10, and the worst offenders score 50-190). So the gallery becomes a selectable
identity backend:
--identity gallery (or DORAYAKI_IDENTITY=gallery)
Measured on the same 20 foreign shelves: false accepts fall from 47.8% to 13.8% at
the default threshold and to **5%** at 0.60, with a smooth curve to pick an operating
point from. Detection is unchanged (the gallery only names boxes).
What it does NOT fix, measured on a milk shelf where 14 of 107 facings really are
明治 おいしい牛乳 (labelled by eye): precision is 28-45%, no better than the matcher's
39%, and enrolling the shelf from itself (1-3 views per product) is flat at ~25%
top-1. Off-the-shelf DINOv2 is not discriminative enough for fine-grained SKUs - the
same conclusion the literature reaches, now measured here.
The packaging's own text is a real channel and resolution-bound: 51% recall
single-scale, 60% with the tag reader's multi-scale + red-channel trick, 85% where
the text is large (MEGMILK) and 28% where it is small vertical Japanese.
docs/EXTERNAL-EVAL.md records all of it, including the identity architecture it
implies: reject by default, name from the tag when readable, from the packaging text
when legible, from a fine-tuned embedding otherwise, and hand the rest to the
operator.
175 tests pass.
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1100 lines
47 KiB
Python
1100 lines
47 KiB
Python
"""
|
|
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 threading
|
|
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)
|
|
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)
|
|
|
|
from fastapi import ( # noqa: E402
|
|
Depends,
|
|
FastAPI,
|
|
Header,
|
|
HTTPException,
|
|
Request,
|
|
WebSocket,
|
|
WebSocketDisconnect,
|
|
)
|
|
from fastapi.exceptions import RequestValidationError # noqa: E402
|
|
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 demo import DemoCamera, DemoSourceError # noqa: E402
|
|
from record import FrameRecorder # noqa: E402
|
|
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"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# settings
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@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
|
|
live_tags: bool = True
|
|
demo_walk: bool = False
|
|
# A live session can be recorded so it becomes data to label (record.py).
|
|
record_frames: Optional[str] = None
|
|
record_every: int = 3
|
|
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)),
|
|
live_tags=os.environ.get("DORAYAKI_LIVE_TAGS", "1").lower() not in ("0", "false", "no"),
|
|
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]]:
|
|
"""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({"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),
|
|
)
|
|
for name in files:
|
|
items.append(
|
|
{
|
|
"id": name.rsplit(".", 1)[0],
|
|
"path": os.path.join(add_dir, name).replace("\\", "/"),
|
|
"label": f"{add_dir}/{name}",
|
|
}
|
|
)
|
|
|
|
mosaic = "runs/detect/train/train_batch0.jpg"
|
|
if os.path.exists(mosaic):
|
|
items.append({"id": "train_mosaic", "path": mosaic, "label": "train_batch0.jpg (Mosaic)"})
|
|
return items
|
|
|
|
|
|
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
|
|
|
|
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=[o.strip() for o in settings.cors_origins.split(",") if o.strip()] or ["*"],
|
|
allow_credentials=False,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
scans = scans if scans is not None else build_legacy_scans(engine)
|
|
samples = {item["id"]: item for item in get_available_shelf_images("add")}
|
|
demo_camera = DemoCamera(samples)
|
|
demo_cameras: Dict[float, DemoCamera] = {}
|
|
sessions: Dict[str, LiveSession] = {}
|
|
sessions_lock = asyncio.Lock()
|
|
app.state.engine = engine
|
|
app.state.sessions = sessions
|
|
|
|
async def authorize(authorization: Optional[str] = Header(default=None)) -> None:
|
|
if not settings.token:
|
|
return
|
|
expected = f"Bearer {settings.token}"
|
|
if not authorization or not hmac.compare_digest(authorization, expected):
|
|
raise _error("UNAUTHORIZED", "missing or invalid bearer token", 401)
|
|
|
|
# ---------------------------------------------------------------- health
|
|
@app.get("/v1/health")
|
|
async def health() -> Dict[str, Any]:
|
|
return {
|
|
"status": "ok",
|
|
"model": {
|
|
"version": engine.model_version(),
|
|
"device": getattr(engine.settings, "device", None) or "auto",
|
|
"imgsz": getattr(engine.settings, "imgsz", 640),
|
|
"warm": True,
|
|
},
|
|
"catalog": {"version": engine.catalog_version(), "sku_count": engine.sku_count()},
|
|
"identity": {
|
|
"backend": getattr(engine.settings, "identity_backend", "matcher"),
|
|
"gallery_views": (
|
|
engine.identity_gallery().view_count()
|
|
if getattr(engine.settings, "identity_backend", "") == "gallery" and engine.identity_gallery()
|
|
else None
|
|
),
|
|
},
|
|
"ocr": {
|
|
# Without this an operator could not tell a broken OCR from a shelf
|
|
# with no readable tags.
|
|
"engine": getattr(engine.ocr_engine, "name", "none"),
|
|
"available": bool(getattr(engine.ocr_engine, "available", lambda: False)()),
|
|
},
|
|
"recording": recorder.stats() if recorder is not None else None,
|
|
"sessions_active": len(sessions),
|
|
"uptime_s": round(engine.uptime_s(), 1),
|
|
}
|
|
|
|
# ---------------------------------------------------------------- catalog
|
|
@app.get("/v1/catalog", dependencies=[Depends(authorize)])
|
|
async def catalog() -> Dict[str, Any]:
|
|
return {"catalog_version": engine.catalog_version(), "products": engine.catalog()}
|
|
|
|
@app.get("/v1/catalog/{sku_id}/image", dependencies=[Depends(authorize)])
|
|
async def catalog_image(sku_id: str) -> Response:
|
|
path = engine.catalog_image_path(sku_id)
|
|
if not path:
|
|
raise _error("NOT_FOUND", f"no catalog image for '{sku_id}'", 404)
|
|
return FileResponse(path)
|
|
|
|
@app.post("/v1/catalog/{sku_id}/stock", dependencies=[Depends(authorize)])
|
|
async def update_stock(sku_id: str, body: StockUpdateBody) -> Dict[str, Any]:
|
|
if body.delta == 0:
|
|
raise _error("VALIDATION_ERROR", "delta must not be zero", 422)
|
|
product = engine.update_stock(sku_id, body.delta)
|
|
if product is None:
|
|
raise _error("NOT_FOUND", f"unknown sku '{sku_id}'", 404)
|
|
return {"status": "success", "product": product}
|
|
|
|
# ---------------------------------------------------------------- samples
|
|
@app.get("/v1/samples", dependencies=[Depends(authorize)])
|
|
async def list_samples() -> Dict[str, Any]:
|
|
listing = []
|
|
for item in samples.values():
|
|
entry = {"id": item["id"], "label": item["label"], "url": f"/v1/samples/{item['id']}"}
|
|
try: # size is a convenience for clients that plan a demo walk
|
|
width, height = demo_camera._size(item["id"])
|
|
entry["width"], entry["height"] = width, height
|
|
except DemoSourceError:
|
|
pass
|
|
listing.append(entry)
|
|
return {"samples": listing}
|
|
|
|
# ---------------------------------------------------------------- demo camera
|
|
# The demo replay lives in its own module: it replaces the live tracks with a
|
|
# still recognition mapped into each walk frame, which is a film-making device,
|
|
# not a perception result. Every payload it produces is marked synthetic.
|
|
demo_recognition = None
|
|
recorder = FrameRecorder(settings.record_frames, every=settings.record_every) \
|
|
if settings.record_frames else None
|
|
|
|
|
|
|
|
def _demo_samples(raw: Optional[str]) -> List[str]:
|
|
if raw:
|
|
wanted = [part.strip() for part in raw.split(",") if part.strip()]
|
|
unknown = [part for part in wanted if part not in samples]
|
|
if unknown:
|
|
raise _error("NOT_FOUND", f"unknown sample(s): {', '.join(unknown)}", 404)
|
|
return wanted
|
|
# main_shelf first: its products are the ones the catalog covers, so a
|
|
# demo walk recognises real items instead of unknown shelf neighbours.
|
|
for preferred in ("main_shelf", "image6"):
|
|
if preferred in samples:
|
|
return [preferred]
|
|
return [next(iter(samples))] if samples else []
|
|
|
|
# Which sample the walk uses, resolved once. The replay object itself is built
|
|
# later, because it needs the still tag pass.
|
|
demo_sample_ids: List[str] = []
|
|
if settings.demo_walk:
|
|
try:
|
|
demo_sample_ids = _demo_samples(None)
|
|
demo_camera.warm(demo_sample_ids)
|
|
except Exception as exc:
|
|
LOGGER.warning("demo walk unavailable: %s", exc)
|
|
|
|
def _demo_window(width: Optional[int], height: Optional[int]) -> tuple:
|
|
win_w = int(width or 960)
|
|
win_h = int(height or 540)
|
|
if not (160 <= win_w <= 2048 and 120 <= win_h <= 2048):
|
|
raise _error("BAD_REQUEST", "window must be between 160x120 and 2048x2048", 400)
|
|
return win_w, win_h
|
|
|
|
def _demo_camera(pan: Optional[float]) -> DemoCamera:
|
|
"""One camera per pacing value (the walk geometry depends on it)."""
|
|
if pan is None:
|
|
return demo_camera
|
|
key = round(float(pan), 4)
|
|
camera = demo_cameras.get(key)
|
|
if camera is None:
|
|
camera = DemoCamera(samples, pan_overlap=key)
|
|
demo_cameras[key] = camera
|
|
return camera
|
|
|
|
@app.get("/v1/demo/plan", dependencies=[Depends(authorize)])
|
|
async def demo_plan(samples_param: Optional[str] = None, width: Optional[int] = None,
|
|
height: Optional[int] = None, pan: Optional[float] = None) -> Dict[str, Any]:
|
|
"""Geometry of the virtual walk (demo-only capture source)."""
|
|
try:
|
|
ids = _demo_samples(samples_param)
|
|
if not ids:
|
|
raise _error("NOT_FOUND", "no samples available", 404)
|
|
camera = _demo_camera(pan)
|
|
return {"status": "success", **camera.plan(ids, _demo_window(width, height))}
|
|
except DemoSourceError as exc:
|
|
raise _error("NOT_FOUND", str(exc), 404)
|
|
|
|
@app.get("/v1/demo/frame", dependencies=[Depends(authorize)])
|
|
async def demo_frame(step: int = 0, samples_param: Optional[str] = None,
|
|
width: Optional[int] = None, height: Optional[int] = None,
|
|
quality: int = 65, pan: Optional[float] = None) -> Response:
|
|
"""One JPEG frame of the virtual walk: a store employee sweeping the shelf.
|
|
|
|
Demo-only. The client advances `step` (it wraps), so the walk's speed
|
|
follows whatever frame rate the client can sustain.
|
|
"""
|
|
try:
|
|
ids = _demo_samples(samples_param)
|
|
if not ids:
|
|
raise _error("NOT_FOUND", "no samples available", 404)
|
|
payload, position = _demo_camera(pan).frame(
|
|
ids, step, _demo_window(width, height), max(30, min(95, int(quality)))
|
|
)
|
|
except DemoSourceError as exc:
|
|
raise _error("NOT_FOUND", str(exc), 404)
|
|
return Response(
|
|
content=payload,
|
|
media_type="image/jpeg",
|
|
headers={
|
|
"X-Demo-Sample": str(position["sample"]),
|
|
"X-Demo-Bay": str(position["bay_index"] + 1),
|
|
"X-Demo-Step": str(position["step"]),
|
|
"X-Demo-Steps": str(position["steps"]),
|
|
"X-Demo-X": str(position["x"]),
|
|
"X-Demo-Y": str(position["y"]),
|
|
},
|
|
)
|
|
|
|
@app.get("/v1/samples/{sample_id}", dependencies=[Depends(authorize)])
|
|
async def get_sample(sample_id: str) -> Response:
|
|
item = samples.get(sample_id)
|
|
if not item or not os.path.exists(item["path"]):
|
|
raise _error("NOT_FOUND", f"no sample '{sample_id}'", 404)
|
|
return FileResponse(item["path"])
|
|
|
|
# ---------------------------------------------------------------- recognize
|
|
@app.post("/v1/recognize", dependencies=[Depends(authorize)])
|
|
async def recognize(
|
|
request: Request,
|
|
classify: bool = True,
|
|
method: Optional[str] = None,
|
|
conf: Optional[float] = None,
|
|
empty_slots: bool = True,
|
|
tags: bool = False,
|
|
) -> Dict[str, Any]:
|
|
if method is not None:
|
|
_require_method(method)
|
|
content_type = (request.headers.get("content-type") or "").split(";")[0].strip().lower()
|
|
if content_type == "multipart/form-data":
|
|
form = await request.form()
|
|
upload = form.get("file")
|
|
if upload is None or not hasattr(upload, "read"):
|
|
raise _error("BAD_REQUEST", "multipart body needs a 'file' field", 400)
|
|
payload = await upload.read()
|
|
else:
|
|
if content_type and not content_type.startswith("image/") and content_type != "application/octet-stream":
|
|
raise _error("UNSUPPORTED_MEDIA_TYPE", f"unsupported content type '{content_type}'", 415)
|
|
payload = await request.body()
|
|
if not payload:
|
|
raise _error("BAD_REQUEST", "empty request body", 400)
|
|
if len(payload) > MAX_UPLOAD_BYTES:
|
|
raise _error("PAYLOAD_TOO_LARGE", f"image larger than {MAX_UPLOAD_BYTES} bytes", 413)
|
|
try:
|
|
image = engine.decode_image(payload)
|
|
except UndecodableImage as exc:
|
|
raise _error("UNDECODABLE_IMAGE", str(exc), 422)
|
|
try:
|
|
result = engine.recognize(
|
|
image,
|
|
classify=classify,
|
|
method=method,
|
|
conf=conf,
|
|
with_empty_slots=empty_slots,
|
|
)
|
|
except UnknownMethod as exc:
|
|
raise _error("BAD_REQUEST", str(exc), 400)
|
|
|
|
if tags:
|
|
result = _attach_still_tags(result, image)
|
|
return result
|
|
|
|
def _attach_still_tags(result: Dict[str, Any], image: Any) -> Dict[str, Any]:
|
|
"""Run one synchronous tag pass over a still and merge its evidence."""
|
|
ocr_engine = getattr(engine, "ocr_engine", None)
|
|
if ocr_engine is None or not getattr(ocr_engine, "available", lambda: False)():
|
|
result["price_tags"] = {"readable": 0, "unreadable": 0, "absent": 0}
|
|
result["compliance"] = {"violations": 0, "by_rule": {}, "rows": [], "hints": []}
|
|
result["ocr_engine"] = "none"
|
|
return result
|
|
|
|
from reconcile import attach_tags_to_boxes
|
|
from tags import TagService, TagSettings
|
|
|
|
boxes = [
|
|
{"id": detection["id"], "box": detection["box"]} for detection in result["detections"]
|
|
]
|
|
identities = {
|
|
int(detection["id"]): detection["identity"] for detection in result["detections"]
|
|
}
|
|
catalog_by_sku = {product["sku_id"]: product for product in engine.catalog()}
|
|
service = TagService(ocr_engine, settings=TagSettings())
|
|
started = time.perf_counter()
|
|
bundle = attach_tags_to_boxes(service, image, boxes, catalog_by_sku, identities)
|
|
elapsed_ms = (time.perf_counter() - started) * 1000.0
|
|
|
|
# Tag-first identity: the store's own label, read from the tag printed
|
|
# under the facings it names. It fills identities the packaging pass could
|
|
# not resolve and never overwrites one it did.
|
|
tag_identities = bundle.get("tag_identities") or {}
|
|
for detection in result["detections"]:
|
|
derived = tag_identities.get(int(detection["id"]))
|
|
if not derived:
|
|
continue
|
|
# The tag's claim is always reported, so a client can compare the two
|
|
# identity channels; it only *fills* the identity when the packaging
|
|
# pass could not name the product.
|
|
detection["tag_identity"] = dict(derived)
|
|
if (detection.get("identity") or {}).get("state") == "recognized" \
|
|
and (detection["identity"].get("sku_id") == derived["sku_id"]
|
|
or detection["identity"].get("source") == "tag_name"):
|
|
continue
|
|
product = catalog_by_sku.get(derived["sku_id"]) or {}
|
|
detection["identity"] = {
|
|
"state": "recognized",
|
|
"sku_id": derived["sku_id"],
|
|
"identity_level": "tag_derived",
|
|
"confidence": derived["score"],
|
|
"score": derived["score"],
|
|
"source": "tag_name",
|
|
"candidates": [],
|
|
"attempts": 1,
|
|
"method": "tag_name",
|
|
}
|
|
detection["product"] = {
|
|
"sku_id": product.get("sku_id"),
|
|
"product_name": product.get("product_name"),
|
|
"price": product.get("price"),
|
|
}
|
|
|
|
for detection in result["detections"]:
|
|
box_id = int(detection["id"])
|
|
tag = bundle["by_box"].get(box_id)
|
|
if tag is not None:
|
|
detection["price_tag"] = tag
|
|
reconciliation = bundle["reconciliations"].get(box_id)
|
|
if reconciliation is not None:
|
|
detection["reconciliation"] = reconciliation
|
|
|
|
result["price_tags"] = bundle["counts"]
|
|
result["compliance"] = bundle["compliance"]
|
|
result["ocr_engine"] = ocr_engine.name
|
|
result["tag_timing_ms"] = round(elapsed_ms, 1)
|
|
engine.image_store.set_tag_bundle(result["image_id"], bundle)
|
|
return result
|
|
|
|
if settings.demo_walk and demo_sample_ids:
|
|
# Built here because the replay needs the still tag pass, and it recognises
|
|
# the whole shelf once up front so the walk's first frame is already
|
|
# labelled. Every payload it produces is marked synthetic.
|
|
try:
|
|
from demo_replay import DemoRecognition
|
|
|
|
demo_recognition = DemoRecognition(engine, demo_camera, samples, _attach_still_tags)
|
|
demo_recognition.warm(demo_sample_ids[0])
|
|
LOGGER.info("demo walk: '%s' recognised up front", demo_sample_ids[0])
|
|
except Exception as exc:
|
|
LOGGER.warning("demo walk warm-up failed: %s", exc)
|
|
|
|
@app.post("/v1/images/{image_id}/classify", dependencies=[Depends(authorize)])
|
|
async def classify_image(image_id: str, body: ClassifyBody) -> Dict[str, Any]:
|
|
method = _require_method(body.method)
|
|
try:
|
|
stored = engine.image_store.get(image_id)
|
|
except ImageExpired:
|
|
raise _error("IMAGE_EXPIRED", f"image '{image_id}' is no longer available", 404)
|
|
detections = stored.detections
|
|
if body.detection_ids is not None:
|
|
wanted = set(int(i) for i in body.detection_ids)
|
|
detections = [d for d in detections if int(d["id"]) in wanted]
|
|
started = time.perf_counter()
|
|
rows = engine.classify_boxes(stored.image, detections, method=method)
|
|
rows_by_id = {row["detection_id"]: row for row in rows}
|
|
for detection in stored.detections:
|
|
row = rows_by_id.get(int(detection["id"]))
|
|
if row is not None:
|
|
detection["identity"] = row["identity"]
|
|
engine.image_store.update_detections(image_id, stored.detections)
|
|
|
|
# Tags were read before the identities existed; reconcile them now.
|
|
compliance = None
|
|
if stored.tag_bundle:
|
|
from reconcile import reconcile_bundle
|
|
|
|
catalog_by_sku = {product["sku_id"]: product for product in engine.catalog()}
|
|
refreshed = reconcile_bundle(stored.tag_bundle, stored.detections, catalog_by_sku)
|
|
for detection in stored.detections:
|
|
decision = refreshed["reconciliations"].get(int(detection["id"]))
|
|
if decision is not None:
|
|
detection["reconciliation"] = decision
|
|
compliance = refreshed["compliance"]
|
|
identities = []
|
|
for detection in detections:
|
|
row = {
|
|
"detection_id": int(detection["id"]),
|
|
"box": list(detection["box"]),
|
|
"identity": detection["identity"],
|
|
}
|
|
# Re-reconciled after classification; without it a client would keep
|
|
# the `tag_only` verdict it got when identities did not exist yet.
|
|
if detection.get("reconciliation") is not None:
|
|
row["reconciliation"] = detection["reconciliation"]
|
|
if detection.get("price_tag") is not None:
|
|
row["price_tag"] = detection["price_tag"]
|
|
identities.append(row)
|
|
summary = engine.summarize(stored.detections, [])
|
|
payload = {
|
|
"image_id": image_id,
|
|
"method": method,
|
|
"identities": identities,
|
|
"summary": summary,
|
|
"timing_ms": {"classify": round((time.perf_counter() - started) * 1000.0, 2)},
|
|
}
|
|
if compliance is not None:
|
|
payload["compliance"] = compliance
|
|
return payload
|
|
|
|
@app.post("/v1/images/{image_id}/inspect", dependencies=[Depends(authorize)])
|
|
async def inspect_image(image_id: str, body: InspectBody) -> Dict[str, Any]:
|
|
method = _require_method(body.method)
|
|
try:
|
|
stored = engine.image_store.get(image_id)
|
|
except ImageExpired:
|
|
raise _error("IMAGE_EXPIRED", f"image '{image_id}' is no longer available", 404)
|
|
return engine.inspect(stored.image, body.box, method=method)
|
|
|
|
# ---------------------------------------------------------------- live stream
|
|
@app.websocket("/v1/stream")
|
|
async def stream(websocket: WebSocket) -> None:
|
|
if settings.token:
|
|
supplied = websocket.query_params.get("token") or ""
|
|
header = websocket.headers.get("authorization") or ""
|
|
expected_header = f"Bearer {settings.token}"
|
|
if not (
|
|
hmac.compare_digest(supplied, settings.token)
|
|
or hmac.compare_digest(header, expected_header)
|
|
):
|
|
await websocket.close(code=1008)
|
|
return
|
|
async with sessions_lock:
|
|
if len(sessions) >= settings.max_sessions:
|
|
await websocket.close(code=1013)
|
|
return
|
|
session_id = str(uuid.uuid4())
|
|
session = LiveSession(
|
|
session_id,
|
|
engine,
|
|
settings=LiveSettings(
|
|
method=settings.live_method,
|
|
conf=settings.live_conf,
|
|
max_inflight_per_session=engine.settings.live_inflight,
|
|
max_attempts=engine.settings.live_attempts,
|
|
tags=settings.live_tags,
|
|
),
|
|
)
|
|
session.refresh_catalog()
|
|
if settings.live_tags:
|
|
session.enable_tags()
|
|
sessions[session_id] = session
|
|
await websocket.accept()
|
|
send_lock = asyncio.Lock()
|
|
loop = asyncio.get_running_loop()
|
|
demo_steps = [0]
|
|
|
|
async def send(payload: Dict[str, Any]) -> None:
|
|
async with send_lock:
|
|
await websocket.send_text(json.dumps(payload))
|
|
|
|
await send(
|
|
{
|
|
"type": "hello",
|
|
"protocol": 1,
|
|
"session_id": session_id,
|
|
"catalog_version": engine.catalog_version(),
|
|
"model": {
|
|
"version": engine.model_version(),
|
|
"device": getattr(engine.settings, "device", None) or "auto",
|
|
"imgsz": getattr(engine.settings, "imgsz", 640),
|
|
},
|
|
"config": session.config(),
|
|
"limits": {"max_frame_bytes": MAX_FRAME_BYTES},
|
|
}
|
|
)
|
|
|
|
async def processing_loop() -> None:
|
|
while not session.closed:
|
|
pending = await loop.run_in_executor(None, session.take_pending, 0.25)
|
|
if pending is None:
|
|
continue
|
|
frame_id, payload = pending
|
|
try:
|
|
result = await loop.run_in_executor(None, session.process_frame, frame_id, payload)
|
|
except UndecodableImage:
|
|
await send({"type": "error", "code": "UNDECODABLE_IMAGE", "frame_id": frame_id,
|
|
"message": "frame is not a readable image"})
|
|
continue
|
|
except Exception as exc: # keep the stream alive on one bad frame
|
|
LOGGER.exception("live frame failed: %s", exc)
|
|
await send({"type": "error", "code": "INTERNAL", "frame_id": frame_id,
|
|
"message": "frame processing failed"})
|
|
continue
|
|
if recorder is not None:
|
|
# Record what the client sent and what we saw in it, so the walk
|
|
# can be labelled later instead of only measured on photographs.
|
|
recorder.write(session_id, frame_id, payload, result)
|
|
if demo_recognition is not None and demo_sample_ids:
|
|
# The walk's frames are served from the shelf's full recognition,
|
|
# so every product is already labelled with name and price.
|
|
sample_id = demo_sample_ids[0]
|
|
step = demo_steps[0]
|
|
try:
|
|
result = await loop.run_in_executor(
|
|
None, demo_recognition.apply, result, sample_id, step
|
|
)
|
|
demo_steps[0] = (step + 1) % max(1, demo_recognition.steps(sample_id))
|
|
except Exception as exc: # a demo failure must not kill the stream
|
|
LOGGER.exception("demo walk frame failed: %s", exc)
|
|
await send(result)
|
|
|
|
async def handle_inspect(message: Dict[str, Any]) -> None:
|
|
request_id = message.get("request_id")
|
|
try:
|
|
track_id = int(message.get("track_id"))
|
|
except (TypeError, ValueError):
|
|
await send({"type": "error", "code": "BAD_MESSAGE", "request_id": request_id,
|
|
"message": "track_id required"})
|
|
return
|
|
method = message.get("method")
|
|
if method is not None:
|
|
try:
|
|
method = _require_method(method)
|
|
except HTTPException as exc:
|
|
await send({"type": "error", "code": "BAD_MESSAGE", "request_id": request_id,
|
|
"message": str(exc.detail)})
|
|
return
|
|
try:
|
|
result = await loop.run_in_executor(None, session.inspect, track_id, method)
|
|
except KeyError:
|
|
await send({"type": "error", "code": "TRACK_NOT_IN_VIEW", "request_id": request_id,
|
|
"message": "track is not in the latest frame"})
|
|
return
|
|
except Exception as exc:
|
|
LOGGER.exception("inspect failed: %s", exc)
|
|
await send({"type": "error", "code": "INTERNAL", "request_id": request_id,
|
|
"message": "inspect failed"})
|
|
return
|
|
payload = {"type": "inspect_result", "request_id": request_id, "track_id": track_id}
|
|
payload.update(result)
|
|
await send(payload)
|
|
|
|
processing = asyncio.create_task(processing_loop())
|
|
|
|
async def handle_control(message: Dict[str, Any]) -> None:
|
|
kind = message.get("type")
|
|
if kind == "config":
|
|
try:
|
|
config = session.configure(
|
|
method=message.get("method"),
|
|
conf=message.get("conf"),
|
|
empty_slots=message.get("empty_slots"),
|
|
tags=message.get("tags"),
|
|
)
|
|
except (TypeError, ValueError):
|
|
await send({"type": "error", "code": "BAD_MESSAGE", "message": "bad config values"})
|
|
return
|
|
await send({"type": "config_ack", "config": config})
|
|
elif kind == "reset":
|
|
session.reset()
|
|
await send({"type": "reset_ack"})
|
|
elif kind == "ping":
|
|
await send({"type": "pong", "t": message.get("t")})
|
|
elif kind == "inspect":
|
|
await handle_inspect(message)
|
|
else:
|
|
await send({"type": "error", "code": "BAD_MESSAGE", "message": f"unknown type '{kind}'"})
|
|
|
|
try:
|
|
while True:
|
|
try:
|
|
message = await asyncio.wait_for(websocket.receive(), timeout=WS_IDLE_TIMEOUT_S)
|
|
except asyncio.TimeoutError:
|
|
break
|
|
if message.get("type") == "websocket.disconnect":
|
|
break
|
|
text = message.get("text")
|
|
data = message.get("bytes")
|
|
if data is not None:
|
|
if len(data) < 5:
|
|
await send({"type": "error", "code": "BAD_FRAME", "message": "frame shorter than 5 bytes"})
|
|
continue
|
|
if len(data) > MAX_FRAME_BYTES + 4:
|
|
await send({"type": "error", "code": "PAYLOAD_TOO_LARGE", "message": "frame too large"})
|
|
continue
|
|
frame_id = int.from_bytes(data[:4], "big")
|
|
session.submit_frame(frame_id, data[4:])
|
|
elif text is not None:
|
|
try:
|
|
parsed = json.loads(text)
|
|
if not isinstance(parsed, dict):
|
|
raise ValueError("not an object")
|
|
except (json.JSONDecodeError, ValueError):
|
|
await send({"type": "error", "code": "BAD_MESSAGE", "message": "invalid JSON"})
|
|
continue
|
|
await handle_control(parsed)
|
|
except WebSocketDisconnect:
|
|
pass
|
|
finally:
|
|
session.close()
|
|
processing.cancel()
|
|
try:
|
|
await processing
|
|
except (asyncio.CancelledError, Exception):
|
|
pass
|
|
async with sessions_lock:
|
|
sessions.pop(session_id, None)
|
|
|
|
# ---------------------------------------------------------------- legacy session routes
|
|
@app.post("/api/sessions")
|
|
async def legacy_start_session(payload: Dict[str, Any] = None) -> Dict[str, Any]:
|
|
payload = payload or {}
|
|
session = scans.start_session(
|
|
zone_id=str(payload.get("zone_id") or "zone-1"),
|
|
catalog_version=str(payload.get("catalog_version") or "stock-v1"),
|
|
capture_kind=str(payload.get("capture_kind") or "sampled"),
|
|
)
|
|
return {"status": "success", "session": session.snapshot()}
|
|
|
|
@app.post("/api/sessions/{session_id}/frames")
|
|
async def legacy_observe_frame(session_id: str, payload: Dict[str, Any] = None) -> Dict[str, Any]:
|
|
from observe import FrameRequest, ObserveError
|
|
|
|
payload = payload or {}
|
|
try:
|
|
session = scans.get_session(session_id)
|
|
raw = payload.get("frame_bytes_b64") or payload.get("image_base64") or ""
|
|
if "," in raw:
|
|
raw = raw.split(",", 1)[1]
|
|
request = FrameRequest(
|
|
frame_id=str(payload.get("frame_id") or ""),
|
|
frame_bytes=base64.b64decode(raw) if raw else b"",
|
|
sequence=int(payload.get("sequence", 0)),
|
|
captured_at=float(payload.get("captured_at") or 0),
|
|
session_id=session.session_id,
|
|
zone_id=session.zone_id,
|
|
catalog_version=session.catalog_version,
|
|
capture_kind=str(payload.get("capture_kind") or session.capture_kind),
|
|
width=payload.get("width"),
|
|
height=payload.get("height"),
|
|
)
|
|
if not request.frame_id:
|
|
raise _error("BAD_REQUEST", "frame_id required", 400)
|
|
observation = scans.observe_frame(request)
|
|
return {"status": "success", "observation": observation.as_dict(), "session": session.snapshot()}
|
|
except ObserveError as exc:
|
|
raise _error("CONFLICT", str(exc), 409)
|
|
|
|
@app.get("/api/sessions/{session_id}")
|
|
async def legacy_get_session(session_id: str) -> Dict[str, Any]:
|
|
from observe import ObserveError
|
|
|
|
try:
|
|
return {"status": "success", "session": scans.get_session(session_id).snapshot()}
|
|
except ObserveError as exc:
|
|
raise _error("NOT_FOUND", str(exc), 404)
|
|
|
|
@app.post("/api/sessions/{session_id}/review")
|
|
async def legacy_review(session_id: str, payload: Dict[str, Any] = None) -> Dict[str, Any]:
|
|
from observe import ObserveError
|
|
|
|
payload = payload or {}
|
|
|
|
try:
|
|
snapshot = scans.review(
|
|
session_id,
|
|
actor=str(payload.get("actor") or "operator"),
|
|
reason=str(payload.get("reason") or "manual-review"),
|
|
note=payload.get("note"),
|
|
)
|
|
return {"status": "success", "session": snapshot}
|
|
except ObserveError as exc:
|
|
raise _error("CONFLICT", str(exc), 409)
|
|
|
|
@app.post("/api/sessions/{session_id}/finalize")
|
|
async def legacy_finalize(session_id: str) -> Dict[str, Any]:
|
|
from observe import ObserveError
|
|
|
|
try:
|
|
return {"status": "success", "session": scans.finalize(session_id)}
|
|
except ObserveError as exc:
|
|
raise _error("CONFLICT", str(exc), 409)
|
|
|
|
@app.post("/api/sessions/{session_id}/cancel")
|
|
async def legacy_cancel(session_id: str) -> Dict[str, Any]:
|
|
from observe import ObserveError
|
|
|
|
try:
|
|
return {"status": "success", "session": scans.cancel(session_id)}
|
|
except ObserveError as exc:
|
|
raise _error("CONFLICT", str(exc), 409)
|
|
|
|
@app.post("/api/sessions/{session_id}/rescan")
|
|
async def legacy_rescan(session_id: str) -> Dict[str, Any]:
|
|
from observe import ObserveError
|
|
|
|
try:
|
|
return {"status": "success", "session": scans.rescan(session_id).snapshot()}
|
|
except ObserveError as exc:
|
|
raise _error("CONFLICT", str(exc), 409)
|
|
|
|
@app.post("/api/compare_live_vs_still")
|
|
async def legacy_compare(payload: Dict[str, Any] = None) -> Dict[str, Any]:
|
|
from observe import ObserveError
|
|
|
|
payload = payload or {}
|
|
try:
|
|
comparison = scans.compare_live_vs_still(
|
|
str(payload.get("sampled_session_id")), str(payload.get("still_session_id"))
|
|
)
|
|
return {"status": "success", "comparison": comparison}
|
|
except ObserveError as exc:
|
|
raise _error("CONFLICT", str(exc), 409)
|
|
|
|
# ---------------------------------------------------------------- errors
|
|
@app.exception_handler(RequestValidationError)
|
|
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
|
|
first = (exc.errors() or [{}])[0]
|
|
location = ".".join(str(part) for part in first.get("loc", []) if part != "body")
|
|
message = first.get("msg", "invalid request")
|
|
detail = f"{location}: {message}" if location else message
|
|
return JSONResponse(
|
|
status_code=422, content={"error": {"code": "VALIDATION_ERROR", "message": detail}}
|
|
)
|
|
|
|
@app.exception_handler(HTTPException)
|
|
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
|
|
detail = exc.detail
|
|
if isinstance(detail, dict) and "error" in detail:
|
|
body = detail
|
|
else:
|
|
body = {"error": {"code": "HTTP_ERROR", "message": str(detail)}}
|
|
return JSONResponse(status_code=exc.status_code, content=body)
|
|
|
|
@app.exception_handler(Exception)
|
|
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
|
LOGGER.exception("unhandled error on %s: %s", request.url.path, exc)
|
|
return JSONResponse(
|
|
status_code=500, content={"error": {"code": "INTERNAL", "message": "internal server error"}}
|
|
)
|
|
|
|
# ---------------------------------------------------------------- static web client
|
|
resolved_web_dir = web_dir or os.path.join(current_dir, "web")
|
|
if os.path.isdir(resolved_web_dir):
|
|
app.mount("/", StaticFiles(directory=resolved_web_dir, html=True), name="web")
|
|
|
|
@app.on_event("shutdown")
|
|
async def _shutdown() -> None:
|
|
for session in list(sessions.values()):
|
|
session.close()
|
|
engine.shutdown()
|
|
|
|
return app
|
|
|
|
|
|
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("--identity", type=str, default=None, choices=["matcher", "gallery"],
|
|
help="which mechanism names products (default matcher)")
|
|
parser.add_argument("--live-inflight", type=int, default=None,
|
|
help="classifications in flight per session (demo walks want a larger budget)")
|
|
parser.add_argument("--live-attempts", type=int, default=None,
|
|
help="classification attempts per track")
|
|
parser.add_argument("--record-frames", type=str, default=None,
|
|
help="write processed frames and their results under this directory")
|
|
parser.add_argument("--record-every", type=int, default=None,
|
|
help="record one frame in every N (default 3)")
|
|
parser.add_argument("--demo-walk", action="store_true",
|
|
help="serve the demo walk's pre-recognised shelf as live tracks")
|
|
parser.add_argument("--no-tags", action="store_true", help="disable price-tag OCR")
|
|
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()
|
|
|
|
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.demo_walk:
|
|
server.demo_walk = True
|
|
if args.no_tags:
|
|
server.live_tags = False
|
|
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.record_frames is not None:
|
|
server.record_frames = args.record_frames
|
|
if args.record_every is not None:
|
|
server.record_every = args.record_every
|
|
if args.identity is not None:
|
|
engine_settings.identity_backend = args.identity
|
|
if args.live_inflight is not None:
|
|
engine_settings.live_inflight = args.live_inflight
|
|
if args.live_attempts is not None:
|
|
engine_settings.live_attempts = args.live_attempts
|
|
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
|
|
|
|
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__":
|
|
main()
|