Web client: price-tag boxes in a distinct style (teal, chip with the parsed price), an OCR section in the product card showing the tag's price, name, discount and the reconciliation verdict with both values, and a compliance strip under the stage. The photo stage now draws the image into the canvas and uses the same containment as the media, so boxes cannot drift from the product (they did: the canvas stretched to the stage while the photo was contained). Server fixes the browser runs forced out: - Photo mode reconciled before identities existed, so every tag read "tag only". The tag bundle is cached with the still and classify re-reconciles against the identities it just produced, returning them per detection. - The still path only ever read the first four bands (plan hands out two per call); it now drains every band, twice at different upscales so the vote has independent reads. - Voting is confidence-weighted and a dead heat stays unread; the plausible shelf-price range is 10-9999, which stops a misread from becoming a ¥26,300 price. The compliance coverage bar moved to 0.7 after 0.5 produced a false "group without price tag" on a shelf where every facing has one. Measured on add/image6.jpg: 8 readable tags with names and prices, 942 ms for the whole still, 7 real price mismatches, no false violations, and hints that say plainly when a row could not be read. Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
887 lines
36 KiB
Python
887 lines
36 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 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 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
|
|
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")}
|
|
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()},
|
|
"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]:
|
|
return {
|
|
"samples": [
|
|
{"id": item["id"], "label": item["label"], "url": f"/v1/samples/{item['id']}"}
|
|
for item in samples.values()
|
|
]
|
|
}
|
|
|
|
@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
|
|
|
|
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
|
|
|
|
@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,
|
|
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()
|
|
|
|
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
|
|
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("--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.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.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()
|