fix: restore reliable authenticated live recognition
This commit is contained in:
parent
0e23425ef0
commit
934615b593
40 changed files with 2556 additions and 820 deletions
|
|
@ -20,7 +20,7 @@ from __future__ import annotations
|
|||
import argparse
|
||||
import asyncio
|
||||
import base64
|
||||
import hmac
|
||||
from contextlib import asynccontextmanager
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
|
@ -74,6 +74,17 @@ from engine import ( # noqa: E402
|
|||
)
|
||||
from record import FrameRecorder # noqa: E402
|
||||
from live import LiveSession, LiveSettings # noqa: E402
|
||||
from transport import ( # noqa: E402
|
||||
BodyLimit,
|
||||
ServiceAdvertiser,
|
||||
advertise_host,
|
||||
bearer_matches,
|
||||
declared_length,
|
||||
is_loopback_host,
|
||||
read_bounded,
|
||||
require_token_for_bind,
|
||||
token_matches,
|
||||
)
|
||||
|
||||
LOGGER = logging.getLogger("dorayaki")
|
||||
|
||||
|
|
@ -94,7 +105,8 @@ class ServerSettings:
|
|||
host: str = "127.0.0.1"
|
||||
port: int = 8080
|
||||
token: Optional[str] = None
|
||||
cors_origins: str = "*"
|
||||
# Empty means same-origin only. "*" reflects every caller and is not a default.
|
||||
cors_origins: str = ""
|
||||
max_sessions: int = DEFAULT_MAX_SESSIONS
|
||||
live_method: str = "hybrid"
|
||||
live_conf: float = 0.1
|
||||
|
|
@ -124,6 +136,26 @@ def server_settings_from_env() -> ServerSettings:
|
|||
)
|
||||
|
||||
|
||||
def load_local_token(path: str = "config/local.env") -> None:
|
||||
"""Fill DORAYAKI_API_TOKEN from an ignored env file. Never prints the value.
|
||||
|
||||
ponytail: one KEY=VALUE file, no quoting. Upgrade path is a real env loader
|
||||
once a second secret needs the same file.
|
||||
"""
|
||||
if os.environ.get("DORAYAKI_API_TOKEN"):
|
||||
return
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
lines = handle.readlines()
|
||||
except OSError:
|
||||
return
|
||||
for line in lines:
|
||||
key, sep, value = line.strip().partition("=")
|
||||
if key == "DORAYAKI_API_TOKEN" and sep and value:
|
||||
os.environ["DORAYAKI_API_TOKEN"] = value
|
||||
return
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# request models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -234,18 +266,29 @@ def create_app(
|
|||
scans: Any = None,
|
||||
) -> FastAPI:
|
||||
settings = settings or server_settings_from_env()
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
yield
|
||||
for session in list(sessions.values()):
|
||||
session.close()
|
||||
engine.shutdown()
|
||||
|
||||
app = FastAPI(
|
||||
title="Dorayaki Recognition Server",
|
||||
version="1.0",
|
||||
description="Shelf product detection, tracking and classification for capture clients.",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
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=["*"],
|
||||
)
|
||||
# Same-origin is the default. A configured list is echoed back; "*" is not.
|
||||
cors = [o.strip() for o in settings.cors_origins.split(",") if o.strip() and o.strip() != "*"]
|
||||
if cors:
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=cors,
|
||||
allow_credentials=False,
|
||||
allow_methods=["GET", "POST"],
|
||||
allow_headers=["Authorization", "Content-Type"],
|
||||
)
|
||||
|
||||
scans = scans if scans is not None else build_legacy_scans(engine)
|
||||
# A live session can be recorded so a real scan becomes data to label later.
|
||||
|
|
@ -260,8 +303,7 @@ def create_app(
|
|||
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):
|
||||
if not bearer_matches(authorization, settings.token):
|
||||
raise _error("UNAUTHORIZED", "missing or invalid bearer token", 401)
|
||||
|
||||
# ---------------------------------------------------------------- health
|
||||
|
|
@ -316,19 +358,22 @@ def create_app(
|
|||
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]:
|
||||
def _sample_listing() -> List[Dict[str, Any]]:
|
||||
listing = []
|
||||
import cv2
|
||||
|
||||
for item in samples.values():
|
||||
entry = {"id": item["id"], "label": item["label"], "url": f"/v1/samples/{item['id']}"}
|
||||
import cv2
|
||||
|
||||
image = cv2.imread(item["path"])
|
||||
if image is not None:
|
||||
entry["width"], entry["height"] = int(image.shape[1]), int(image.shape[0])
|
||||
listing.append(entry)
|
||||
return {"samples": listing}
|
||||
return listing
|
||||
|
||||
@app.get("/v1/samples", dependencies=[Depends(authorize)])
|
||||
async def list_samples() -> Dict[str, Any]:
|
||||
loop = asyncio.get_running_loop()
|
||||
return {"samples": await loop.run_in_executor(None, _sample_listing)}
|
||||
|
||||
@app.get("/v1/samples/{sample_id}", dependencies=[Depends(authorize)])
|
||||
async def get_sample(sample_id: str) -> Response:
|
||||
|
|
@ -350,37 +395,44 @@ def create_app(
|
|||
if method is not None:
|
||||
_require_method(method)
|
||||
content_type = (request.headers.get("content-type") or "").split(";")[0].strip().lower()
|
||||
announced = declared_length(request.headers.get("content-length"))
|
||||
if announced is not None and announced > MAX_UPLOAD_BYTES:
|
||||
raise _error("PAYLOAD_TOO_LARGE", f"image larger than {MAX_UPLOAD_BYTES} bytes", 413)
|
||||
if content_type == "multipart/form-data":
|
||||
form = await request.form()
|
||||
form = await request.form(max_part_size=MAX_UPLOAD_BYTES)
|
||||
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()
|
||||
payload = await read_bounded(upload, MAX_UPLOAD_BYTES)
|
||||
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()
|
||||
payload = await read_bounded(request.stream(), MAX_UPLOAD_BYTES)
|
||||
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)
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
image = engine.decode_image(payload)
|
||||
image = await loop.run_in_executor(None, 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,
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: 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)
|
||||
result = await loop.run_in_executor(None, _attach_still_tags, result, image)
|
||||
return result
|
||||
|
||||
def _is_price_card(crop: Any) -> bool:
|
||||
|
|
@ -514,7 +566,10 @@ def create_app(
|
|||
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)
|
||||
loop = asyncio.get_running_loop()
|
||||
rows = await loop.run_in_executor(
|
||||
None, lambda: 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"]))
|
||||
|
|
@ -567,21 +622,24 @@ def create_app(
|
|||
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)
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(
|
||||
None, lambda: 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
|
||||
# Query tokens are rejected. A wrong header is rejected before hello.
|
||||
# A missing header is the browser case: hello goes out, then the first
|
||||
# client text must be {"type":"auth","token":"..."} or the socket closes.
|
||||
if websocket.query_params.get("token"):
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
header = websocket.headers.get("authorization")
|
||||
if settings.token and header and not bearer_matches(header, settings.token):
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
async with sessions_lock:
|
||||
if len(sessions) >= settings.max_sessions:
|
||||
await websocket.close(code=1013)
|
||||
|
|
@ -644,9 +702,10 @@ def create_app(
|
|||
"message": "frame processing failed"})
|
||||
continue
|
||||
if recorder is not None:
|
||||
# Record what the client sent and what we saw in it, so a real
|
||||
# scan can be labelled later instead of only measured on photos.
|
||||
recorder.write(session_id, frame_id, payload, result)
|
||||
# Disk writes must not sit on the event loop between results.
|
||||
await loop.run_in_executor(
|
||||
None, recorder.write, session_id, frame_id, payload, result
|
||||
)
|
||||
await send(result)
|
||||
|
||||
async def handle_inspect(message: Dict[str, Any]) -> None:
|
||||
|
|
@ -682,8 +741,23 @@ def create_app(
|
|||
|
||||
processing = asyncio.create_task(processing_loop())
|
||||
|
||||
async def handle_control(message: Dict[str, Any]) -> None:
|
||||
authed = not settings.token or bearer_matches(
|
||||
websocket.headers.get("authorization"), settings.token
|
||||
)
|
||||
|
||||
async def handle_control(message: Dict[str, Any]) -> bool:
|
||||
"""Apply one control message. False closes the socket (auth failed)."""
|
||||
nonlocal authed
|
||||
kind = message.get("type")
|
||||
if not authed:
|
||||
presented = message.get("token") if kind == "auth" else None
|
||||
if isinstance(presented, str) and token_matches(presented, settings.token):
|
||||
authed = True
|
||||
return True
|
||||
await websocket.close(code=1008)
|
||||
return False
|
||||
if kind == "auth":
|
||||
return True
|
||||
if kind == "config":
|
||||
try:
|
||||
config = session.configure(
|
||||
|
|
@ -694,7 +768,7 @@ def create_app(
|
|||
)
|
||||
except (TypeError, ValueError):
|
||||
await send({"type": "error", "code": "BAD_MESSAGE", "message": "bad config values"})
|
||||
return
|
||||
return True
|
||||
await send({"type": "config_ack", "config": config})
|
||||
elif kind == "reset":
|
||||
session.reset()
|
||||
|
|
@ -705,6 +779,7 @@ def create_app(
|
|||
await handle_inspect(message)
|
||||
else:
|
||||
await send({"type": "error", "code": "BAD_MESSAGE", "message": f"unknown type '{kind}'"})
|
||||
return True
|
||||
|
||||
try:
|
||||
while True:
|
||||
|
|
@ -717,6 +792,9 @@ def create_app(
|
|||
text = message.get("text")
|
||||
data = message.get("bytes")
|
||||
if data is not None:
|
||||
if not authed:
|
||||
await websocket.close(code=1008)
|
||||
break
|
||||
if len(data) < 5:
|
||||
await send({"type": "error", "code": "BAD_FRAME", "message": "frame shorter than 5 bytes"})
|
||||
continue
|
||||
|
|
@ -733,7 +811,8 @@ def create_app(
|
|||
except (json.JSONDecodeError, ValueError):
|
||||
await send({"type": "error", "code": "BAD_MESSAGE", "message": "invalid JSON"})
|
||||
continue
|
||||
await handle_control(parsed)
|
||||
if not await handle_control(parsed):
|
||||
break
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
|
|
@ -747,7 +826,7 @@ def create_app(
|
|||
sessions.pop(session_id, None)
|
||||
|
||||
# ---------------------------------------------------------------- legacy session routes
|
||||
@app.post("/api/sessions")
|
||||
@app.post("/api/sessions", dependencies=[Depends(authorize)])
|
||||
async def legacy_start_session(payload: Dict[str, Any] = None) -> Dict[str, Any]:
|
||||
payload = payload or {}
|
||||
session = scans.start_session(
|
||||
|
|
@ -757,7 +836,7 @@ def create_app(
|
|||
)
|
||||
return {"status": "success", "session": session.snapshot()}
|
||||
|
||||
@app.post("/api/sessions/{session_id}/frames")
|
||||
@app.post("/api/sessions/{session_id}/frames", dependencies=[Depends(authorize)])
|
||||
async def legacy_observe_frame(session_id: str, payload: Dict[str, Any] = None) -> Dict[str, Any]:
|
||||
from observe import FrameRequest, ObserveError
|
||||
|
||||
|
|
@ -765,11 +844,19 @@ def create_app(
|
|||
try:
|
||||
session = scans.get_session(session_id)
|
||||
raw = payload.get("frame_bytes_b64") or payload.get("image_base64") or ""
|
||||
if not isinstance(raw, str):
|
||||
raw = ""
|
||||
if "," in raw:
|
||||
raw = raw.split(",", 1)[1]
|
||||
# Base64 expands ~4/3. Reject the text before decoding it.
|
||||
if len(raw) > (MAX_FRAME_BYTES * 4) // 3 + 8:
|
||||
raise _error("PAYLOAD_TOO_LARGE", f"frame larger than {MAX_FRAME_BYTES} bytes", 413)
|
||||
frame_bytes = base64.b64decode(raw) if raw else b""
|
||||
if len(frame_bytes) > MAX_FRAME_BYTES:
|
||||
raise _error("PAYLOAD_TOO_LARGE", f"frame larger than {MAX_FRAME_BYTES} bytes", 413)
|
||||
request = FrameRequest(
|
||||
frame_id=str(payload.get("frame_id") or ""),
|
||||
frame_bytes=base64.b64decode(raw) if raw else b"",
|
||||
frame_bytes=frame_bytes,
|
||||
sequence=int(payload.get("sequence", 0)),
|
||||
captured_at=float(payload.get("captured_at") or 0),
|
||||
session_id=session.session_id,
|
||||
|
|
@ -781,12 +868,13 @@ def create_app(
|
|||
)
|
||||
if not request.frame_id:
|
||||
raise _error("BAD_REQUEST", "frame_id required", 400)
|
||||
observation = scans.observe_frame(request)
|
||||
loop = asyncio.get_running_loop()
|
||||
observation = await loop.run_in_executor(None, 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}")
|
||||
@app.get("/api/sessions/{session_id}", dependencies=[Depends(authorize)])
|
||||
async def legacy_get_session(session_id: str) -> Dict[str, Any]:
|
||||
from observe import ObserveError
|
||||
|
||||
|
|
@ -795,7 +883,7 @@ def create_app(
|
|||
except ObserveError as exc:
|
||||
raise _error("NOT_FOUND", str(exc), 404)
|
||||
|
||||
@app.post("/api/sessions/{session_id}/review")
|
||||
@app.post("/api/sessions/{session_id}/review", dependencies=[Depends(authorize)])
|
||||
async def legacy_review(session_id: str, payload: Dict[str, Any] = None) -> Dict[str, Any]:
|
||||
from observe import ObserveError
|
||||
|
||||
|
|
@ -812,7 +900,7 @@ def create_app(
|
|||
except ObserveError as exc:
|
||||
raise _error("CONFLICT", str(exc), 409)
|
||||
|
||||
@app.post("/api/sessions/{session_id}/finalize")
|
||||
@app.post("/api/sessions/{session_id}/finalize", dependencies=[Depends(authorize)])
|
||||
async def legacy_finalize(session_id: str) -> Dict[str, Any]:
|
||||
from observe import ObserveError
|
||||
|
||||
|
|
@ -821,7 +909,7 @@ def create_app(
|
|||
except ObserveError as exc:
|
||||
raise _error("CONFLICT", str(exc), 409)
|
||||
|
||||
@app.post("/api/sessions/{session_id}/cancel")
|
||||
@app.post("/api/sessions/{session_id}/cancel", dependencies=[Depends(authorize)])
|
||||
async def legacy_cancel(session_id: str) -> Dict[str, Any]:
|
||||
from observe import ObserveError
|
||||
|
||||
|
|
@ -830,7 +918,7 @@ def create_app(
|
|||
except ObserveError as exc:
|
||||
raise _error("CONFLICT", str(exc), 409)
|
||||
|
||||
@app.post("/api/sessions/{session_id}/rescan")
|
||||
@app.post("/api/sessions/{session_id}/rescan", dependencies=[Depends(authorize)])
|
||||
async def legacy_rescan(session_id: str) -> Dict[str, Any]:
|
||||
from observe import ObserveError
|
||||
|
||||
|
|
@ -839,7 +927,7 @@ def create_app(
|
|||
except ObserveError as exc:
|
||||
raise _error("CONFLICT", str(exc), 409)
|
||||
|
||||
@app.post("/api/compare_live_vs_still")
|
||||
@app.post("/api/compare_live_vs_still", dependencies=[Depends(authorize)])
|
||||
async def legacy_compare(payload: Dict[str, Any] = None) -> Dict[str, Any]:
|
||||
from observe import ObserveError
|
||||
|
||||
|
|
@ -881,14 +969,11 @@ def create_app(
|
|||
|
||||
# ---------------------------------------------------------------- static web client
|
||||
resolved_web_dir = web_dir or os.path.join(current_dir, "web")
|
||||
app.add_middleware(BodyLimit, max_bytes=MAX_UPLOAD_BYTES)
|
||||
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
|
||||
|
||||
|
|
@ -960,6 +1045,11 @@ def main() -> None:
|
|||
if args.log_level is not None:
|
||||
server.log_level = args.log_level
|
||||
|
||||
load_local_token()
|
||||
if not server.token:
|
||||
server.token = os.environ.get("DORAYAKI_API_TOKEN") or None
|
||||
require_token_for_bind(server.host, server.token)
|
||||
|
||||
engine_settings = settings_from_env()
|
||||
if args.record_frames is not None:
|
||||
server.record_frames = args.record_frames
|
||||
|
|
@ -996,23 +1086,34 @@ def main() -> None:
|
|||
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"):
|
||||
if is_loopback_host(server.host):
|
||||
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,
|
||||
)
|
||||
advertised = advertise_host(server.host)
|
||||
advertiser = ServiceAdvertiser(advertised, server.port) if advertised else None
|
||||
if advertiser is not None and advertiser.start():
|
||||
LOGGER.info("Advertising _dorayaki._tcp on %s:%d", advertised, server.port)
|
||||
elif advertised:
|
||||
LOGGER.info("No dns-sd on this host; _dorayaki._tcp was not advertised")
|
||||
|
||||
try:
|
||||
uvicorn.run(
|
||||
app,
|
||||
host=server.host,
|
||||
port=server.port,
|
||||
log_level=(server.log_level or "info").lower(),
|
||||
access_log=False, # request lines can carry Authorization; keep them off
|
||||
ws="websockets-sansio",
|
||||
ssl_certfile=server.ssl_certfile,
|
||||
ssl_keyfile=server.ssl_keyfile,
|
||||
)
|
||||
finally:
|
||||
if advertiser is not None:
|
||||
advertiser.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -1,103 +0,0 @@
|
|||
"""
|
||||
Benchmark Suite for Supermarket Product Classification
|
||||
Compares SURF, ORB, BRISK, and Hybrid methods following
|
||||
MDPI Electronics 2023, 12(17), 3640.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import cv2
|
||||
import numpy as np
|
||||
from typing import Dict, List, Any
|
||||
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
if current_dir not in sys.path:
|
||||
sys.path.insert(0, current_dir)
|
||||
|
||||
from feature_matcher import ProductFeatureMatcher
|
||||
|
||||
|
||||
def run_benchmark(stock_dir: str = "stock", num_runs: int = 3):
|
||||
print("=" * 70)
|
||||
print("RETAIL SHELF PRODUCT RECOGNITION - ALGORITHM BENCHMARK")
|
||||
print("Methods Evaluated: SURF | ORB | BRISK | HYBRID (Paper Method)")
|
||||
print("=" * 70)
|
||||
|
||||
matcher = ProductFeatureMatcher(stock_dir=stock_dir)
|
||||
templates = matcher.db.templates
|
||||
|
||||
if not templates:
|
||||
print("[Error] No templates found in stock directory.")
|
||||
return
|
||||
|
||||
methods = ["surf", "orb", "brisk", "hybrid"]
|
||||
results = {m: {"latencies": [], "inliers": [], "keypoints_query": []} for m in methods}
|
||||
|
||||
# Test images: We will evaluate using crops synthesized with slight transformations
|
||||
# (scale, rotation, noise) as well as the template images themselves
|
||||
print(f"\nEvaluating across {len(templates)} catalog SKUs (repeat count: {num_runs})...\n")
|
||||
|
||||
for sku_id, tmpl in templates.items():
|
||||
original_img = tmpl["image"]
|
||||
h, w = original_img.shape[:2]
|
||||
|
||||
# Apply synthetic shelf condition (rotation by 10 deg, resize by 0.85)
|
||||
M = cv2.getRotationMatrix2D((w / 2, h / 2), 10, 0.85)
|
||||
query_img = cv2.warpAffine(original_img, M, (w, h))
|
||||
|
||||
for method in methods:
|
||||
times = []
|
||||
for _ in range(num_runs):
|
||||
t0 = time.perf_counter()
|
||||
res = matcher.classify_crop(query_img, method=method)
|
||||
t1 = time.perf_counter()
|
||||
times.append((t1 - t0) * 1000.0) # ms
|
||||
|
||||
avg_latency = np.mean(times)
|
||||
results[method]["latencies"].append(avg_latency)
|
||||
|
||||
# Record inliers/score
|
||||
if res.get("status") == "success":
|
||||
top_sku = res["best_sku"]
|
||||
score = res["score"]
|
||||
results[method]["inliers"].append(score)
|
||||
|
||||
# Summarize metrics
|
||||
summary_rows = []
|
||||
for method in methods:
|
||||
latencies = results[method]["latencies"]
|
||||
scores = results[method]["inliers"]
|
||||
|
||||
avg_lat = np.mean(latencies) if latencies else 0.0
|
||||
p95_lat = np.percentile(latencies, 95) if latencies else 0.0
|
||||
avg_score = np.mean(scores) if scores else 0.0
|
||||
|
||||
# Paper characteristics notes
|
||||
notes = {
|
||||
"surf": "Scale & rotation robust; Hessian gradient features",
|
||||
"orb": "Ultra fast binary descriptor; FAST + BRIEF",
|
||||
"brisk": "Scale-space AGAST; circular pattern descriptor",
|
||||
"hybrid": "Paper fusion (SURF+ORB+BRISK); optimal accuracy/speed"
|
||||
}[method]
|
||||
|
||||
summary_rows.append([
|
||||
method.upper(),
|
||||
f"{avg_lat:.2f} ms",
|
||||
f"{p95_lat:.2f} ms",
|
||||
f"{avg_score:.1f}",
|
||||
notes
|
||||
])
|
||||
|
||||
headers = ["Method", "Avg Latency (ms)", "P95 Latency (ms)", "Avg Match Score", "Characteristics / Paper Findings"]
|
||||
print("\n" + "-" * 105)
|
||||
print(f"{headers[0]:<10} | {headers[1]:<18} | {headers[2]:<18} | {headers[3]:<16} | {headers[4]}")
|
||||
print("-" * 105)
|
||||
for row in summary_rows:
|
||||
print(f"{row[0]:<10} | {row[1]:<18} | {row[2]:<18} | {row[3]:<16} | {row[4]}")
|
||||
print("-" * 105)
|
||||
print("\n[Benchmark Complete] All methods operational.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_benchmark()
|
||||
|
|
@ -46,16 +46,21 @@ store = "stock-mediya"
|
|||
shutil.rmtree(store, ignore_errors=True)
|
||||
os.makedirs(store, exist_ok=True)
|
||||
rows = ["SKU_ID,product_name,receipt_date,expiration_date,number_in,number_out,number_on,image_link,price"]
|
||||
written = []
|
||||
for sku, name, indices in PRODUCTS:
|
||||
saved = 0
|
||||
for position, index in enumerate(indices):
|
||||
box = boxes[index]
|
||||
x1, y1, x2, y2 = [int(v) for v in box]
|
||||
crop = image[max(0,y1):y2, max(0,x1):x2]
|
||||
crop = image[max(0, y1):y2, max(0, x1):x2]
|
||||
if crop.size == 0:
|
||||
continue
|
||||
suffix = "" if position == 0 else f"_{position}"
|
||||
suffix = "" if saved == 0 else f"_{saved}"
|
||||
cv2.imwrite(os.path.join(store, f"{sku}{suffix}.jpeg"), crop)
|
||||
saved += 1
|
||||
if saved == 0:
|
||||
raise SystemExit(f"{sku}: no readable crop; refusing a catalog row with no view")
|
||||
written.append(sku)
|
||||
rows.append(f"{sku},{name},2026-09-25,2026-10-20,24,8,16,'',{PRICES[sku]}")
|
||||
open(os.path.join(store, "stock.csv"), "w").write("\n".join(rows) + "\n")
|
||||
print(f"{store}: {len(PRODUCTS)} products, "
|
||||
f"{sum(len(i) for _, _, i in PRODUCTS)} views, csv written")
|
||||
print(f"{store}: {len(written)} products, csv written")
|
||||
|
|
|
|||
|
|
@ -1,41 +0,0 @@
|
|||
import os
|
||||
import sys
|
||||
import io
|
||||
import urllib.request
|
||||
from PIL import Image
|
||||
|
||||
# Ensure stdout handles utf-8 cleanly
|
||||
if sys.platform.startswith('win'):
|
||||
sys.stdout.reconfigure(encoding='utf-8')
|
||||
|
||||
urls = {
|
||||
'sku_011': ('https://www.kagome.co.jp/products/brand/ys100/regular/img/img_products_apple@2x_v2.png', '野菜生活100 アップルサラダ'),
|
||||
'sku_012': ('https://www.kagome.co.jp/products/brand/ys100/regular/img/img_products_lemon@2x_v2.png', '野菜生活100 レモンサラダ'),
|
||||
'sku_013': ('https://www.kagome.co.jp/products/brand/ys100/yssmoothie/img/green-product-pic_2.png', '野菜生活100 Smoothie グリーンスムージー'),
|
||||
'sku_014': ('https://www.kagome.co.jp/products/brand/ys100/yssmoothie/img/orange-product-pic_2.png', '野菜生活100 Smoothie ビタミンスムージー'),
|
||||
'sku_015': ('https://www.kagome.co.jp/products/brand/ys100/yssmoothie/img/yellow-product-pic_2.png', '野菜生活100 Smoothie バナナ&アーモンドmix'),
|
||||
'sku_016': ('https://www.kagome.co.jp/products/brand/ys100/yssmoothie/img/red-product-pic_2.png', '野菜生活100 Smoothie アサイー&ベリーmix')
|
||||
}
|
||||
|
||||
os.makedirs('stock', exist_ok=True)
|
||||
|
||||
for sku, (url, name) in urls.items():
|
||||
print(f'Fetching {sku}: {name} from {url}...')
|
||||
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
data = resp.read()
|
||||
img = Image.open(io.BytesIO(data))
|
||||
if img.mode in ('RGBA', 'LA') or (img.mode == 'P' and 'transparency' in img.info):
|
||||
bg = Image.new('RGB', img.size, (255, 255, 255))
|
||||
if img.mode != 'RGBA':
|
||||
img = img.convert('RGBA')
|
||||
bg.paste(img, mask=img.split()[3])
|
||||
img = bg
|
||||
else:
|
||||
img = img.convert('RGB')
|
||||
out_path = f'stock/{sku}.jpeg'
|
||||
img.save(out_path, 'JPEG', quality=95)
|
||||
print(f'Successfully saved {sku}: {name} -> {out_path} ({img.size})')
|
||||
except Exception as e:
|
||||
print(f'Error downloading {sku} ({url}): {e}')
|
||||
|
|
@ -251,6 +251,7 @@ class RecognitionEngine:
|
|||
# The embedding gallery is built on first use (it loads a model and every
|
||||
# enrolled view), and cached as False when it cannot be built at all.
|
||||
self._gallery: Any = None
|
||||
self._gallery_lock = threading.Lock()
|
||||
workers = self.settings.classify_workers or max(2, (os.cpu_count() or 4) - 2)
|
||||
self.classify_workers = int(workers)
|
||||
self._executor = classify_executor or ThreadPoolExecutor(
|
||||
|
|
@ -417,23 +418,22 @@ class RecognitionEngine:
|
|||
return "cosine" if (self.settings.identity_backend or "matcher").lower() == "gallery" else "inlier"
|
||||
|
||||
def score_crop(self, crop: Any, method: Optional[str] = None) -> Dict[str, float]:
|
||||
"""Score one crop against the catalog, in whatever scale this engine uses.
|
||||
"""Score one crop. Gallery never falls back to the matcher.
|
||||
|
||||
The live session calls this per track. With the gallery backend it must go
|
||||
through the gallery, or a live scan would use the matcher while the still path
|
||||
used the enrolled store - measured: the matcher accepts look-alikes and cannot
|
||||
say "unknown", which is exactly what a real shelf needs it to do.
|
||||
A missing gallery returns no scores (unknown), not inlier counts. Mixing
|
||||
those scales is how a live scan accepted a look-alike the still path rejected.
|
||||
"""
|
||||
if self.identity_scale == "cosine":
|
||||
gallery = self.identity_gallery()
|
||||
if gallery:
|
||||
match = gallery.match(crop)
|
||||
if match.rejected or not match.sku_id:
|
||||
return {}
|
||||
scores = {match.sku_id: match.similarity}
|
||||
if match.runner_up:
|
||||
scores[match.runner_up] = match.runner_up_similarity
|
||||
return {str(sku): float(score) for sku, score in scores.items()}
|
||||
if not gallery:
|
||||
return {}
|
||||
match = gallery.match(crop)
|
||||
if match.rejected or not match.sku_id:
|
||||
return {}
|
||||
scores = {match.sku_id: match.similarity}
|
||||
if match.runner_up:
|
||||
scores[match.runner_up] = match.runner_up_similarity
|
||||
return {str(sku): float(score) for sku, score in scores.items()}
|
||||
method = (method or self.settings.live_method).lower()
|
||||
if method not in CLASSIFY_METHODS:
|
||||
raise UnknownMethod(method)
|
||||
|
|
@ -445,27 +445,41 @@ class RecognitionEngine:
|
|||
|
||||
def identity_gallery(self) -> Any:
|
||||
"""The embedding gallery, loaded once (None when unavailable)."""
|
||||
if self._gallery is False:
|
||||
return None
|
||||
if self._gallery is not None:
|
||||
return self._gallery
|
||||
from gallery import ProductGallery
|
||||
with self._gallery_lock:
|
||||
if self._gallery is False:
|
||||
return None
|
||||
if self._gallery is not None:
|
||||
return self._gallery
|
||||
from gallery import ProductGallery
|
||||
|
||||
gallery = ProductGallery(
|
||||
self.settings.stock_dir,
|
||||
[product["sku_id"] for product in self.catalog()],
|
||||
threshold=self.settings.gallery_threshold,
|
||||
head_threshold=self.settings.gallery_head_threshold,
|
||||
image_size=self.settings.gallery_image_size,
|
||||
)
|
||||
try:
|
||||
loaded = gallery.load()
|
||||
except Exception as exc: # torch/timm missing, no weights, no network
|
||||
LOGGER.warning("identity gallery unavailable: %s", exc)
|
||||
loaded = False
|
||||
self._gallery = gallery if loaded else False
|
||||
if loaded:
|
||||
LOGGER.info("identity gallery: %d views over %d SKUs on %s",
|
||||
gallery.view_count(), len(self.settings.stock_dir and gallery.sku_ids), gallery.device)
|
||||
return self._gallery
|
||||
gallery = ProductGallery(
|
||||
self.settings.stock_dir,
|
||||
[product["sku_id"] for product in self.catalog()],
|
||||
threshold=self.settings.gallery_threshold,
|
||||
head_threshold=self.settings.gallery_head_threshold,
|
||||
image_size=self.settings.gallery_image_size,
|
||||
)
|
||||
try:
|
||||
loaded = gallery.load()
|
||||
except Exception as exc: # torch/timm missing, no weights, no network
|
||||
LOGGER.warning("identity gallery unavailable: %s", exc)
|
||||
loaded = False
|
||||
# False means tried and failed. Do not retry and do not use the matcher.
|
||||
self._gallery = gallery if loaded else False
|
||||
if loaded:
|
||||
LOGGER.info(
|
||||
"identity gallery: %d views over %d SKUs on %s",
|
||||
gallery.view_count(),
|
||||
len(gallery.sku_ids),
|
||||
gallery.device,
|
||||
)
|
||||
else:
|
||||
LOGGER.warning("identity gallery unavailable; gallery requests stay unrecognized")
|
||||
return gallery if loaded else None
|
||||
|
||||
def classify_boxes(
|
||||
self,
|
||||
|
|
@ -493,8 +507,8 @@ class RecognitionEngine:
|
|||
|
||||
if (self.settings.identity_backend or "matcher").lower() == "gallery":
|
||||
gallery = self.identity_gallery()
|
||||
if gallery:
|
||||
return self._classify_with_gallery(gallery, ordered, crops)
|
||||
# No matcher fallback: an unavailable gallery is unrecognized, not a different model.
|
||||
return self._classify_with_gallery(gallery, ordered, crops)
|
||||
if method == "hybrid_fast":
|
||||
futures = [self._executor.submit(self.matcher.score_crop_fast, crop) for crop in crops]
|
||||
else:
|
||||
|
|
@ -516,26 +530,47 @@ class RecognitionEngine:
|
|||
return results
|
||||
|
||||
def inspect(self, image: Any, box: Sequence[int], method: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Full single-crop result: decision, per-method details, crop, visualization."""
|
||||
from identity import decide_identity
|
||||
"""Full single-crop result, using the same backend as recognize/live."""
|
||||
from identity import decide_identity, decision_for_match
|
||||
|
||||
crop = self.crop(image, box)
|
||||
if self.identity_scale == "cosine":
|
||||
gallery = self.identity_gallery()
|
||||
match = gallery.match(crop) if gallery else None
|
||||
# match() already applied the gallery's own threshold. Ranking again
|
||||
# with the published cosine gates must not invent a second line.
|
||||
decision = decision_for_match(match)
|
||||
return self._inspect_payload(box, "gallery", decision, crop, visualize=False)
|
||||
|
||||
method = (method or self.settings.live_method).lower()
|
||||
if method not in CLASSIFY_METHODS:
|
||||
raise UnknownMethod(method)
|
||||
crop = self.crop(image, box)
|
||||
if method == "hybrid_fast":
|
||||
scores, details = self.matcher.score_crop_fast(crop)
|
||||
visual_method = "hybrid"
|
||||
else:
|
||||
scores, details = self.matcher.score_crop(crop, method=method)
|
||||
visual_method = method
|
||||
decision = decide_identity(scores, details=details, method=method)
|
||||
identity = identity_payload(decision)
|
||||
return self._inspect_payload(box, method, decision, crop, visual_method=visual_method)
|
||||
|
||||
def _inspect_payload(
|
||||
self,
|
||||
box: Sequence[int],
|
||||
method: str,
|
||||
decision: Dict[str, Any],
|
||||
crop: Any,
|
||||
*,
|
||||
visual_method: Optional[str] = None,
|
||||
visualize: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
identity = identity_payload(decision)
|
||||
top_details = decision.get("details") or {}
|
||||
sub_details = None
|
||||
if isinstance(top_details, dict):
|
||||
raw = top_details.get("sub_details")
|
||||
if raw:
|
||||
sub_details = {k: dict(v) for k, v in raw.items()}
|
||||
sub_details = {key: dict(value) for key, value in raw.items()}
|
||||
|
||||
product = None
|
||||
visualization = None
|
||||
|
|
@ -547,16 +582,16 @@ class RecognitionEngine:
|
|||
elif identity["candidates"]:
|
||||
visualized_sku = identity["candidates"][0]["sku_id"]
|
||||
is_candidate = True
|
||||
if visualized_sku:
|
||||
if visualize and visualized_sku and visual_method:
|
||||
try:
|
||||
visualization = self.matcher.generate_match_visualization(
|
||||
crop, visualized_sku, method=method if method != "hybrid_fast" else "hybrid"
|
||||
crop, visualized_sku, method=visual_method
|
||||
)
|
||||
except Exception:
|
||||
visualization = None
|
||||
|
||||
return {
|
||||
"box": [int(v) for v in box],
|
||||
"box": [int(value) for value in box],
|
||||
"method": method,
|
||||
"identity": identity,
|
||||
"sub_details": sub_details,
|
||||
|
|
@ -724,48 +759,22 @@ class RecognitionEngine:
|
|||
unseen store photos, the matcher labelled milk cartons as mayonnaise and its
|
||||
score could not be thresholded to stop it.
|
||||
"""
|
||||
from identity import decision_for_match
|
||||
|
||||
results: List[Dict[str, Any]] = []
|
||||
for box, crop in zip(ordered, crops):
|
||||
try:
|
||||
match = gallery.match(crop)
|
||||
except Exception as exc: # a bad crop must not fail the batch
|
||||
match = None
|
||||
LOGGER.debug("gallery match failed: %s", exc)
|
||||
if match is None or match.rejected:
|
||||
similarity = float(match.similarity) if match is not None else 0.0
|
||||
results.append({
|
||||
"detection_id": int(box["id"]),
|
||||
"box": list(box["box"]),
|
||||
"identity": {
|
||||
"state": "unrecognized",
|
||||
"sku_id": None,
|
||||
"identity_level": "rejected",
|
||||
"confidence": round(similarity, 3),
|
||||
"score": round(similarity, 3),
|
||||
"candidates": [],
|
||||
"attempts": 1,
|
||||
"method": "gallery",
|
||||
"message": "no enrolled product is close enough",
|
||||
},
|
||||
})
|
||||
continue
|
||||
runner_up = (
|
||||
{"sku_id": match.runner_up, "score": round(match.runner_up_similarity, 3)}
|
||||
if match.runner_up else {}
|
||||
)
|
||||
match = None
|
||||
if gallery is not None:
|
||||
try:
|
||||
match = gallery.match(crop)
|
||||
except Exception as exc: # a bad crop must not fail the batch
|
||||
match = None
|
||||
LOGGER.debug("gallery match failed: %s", exc)
|
||||
decision = decision_for_match(match)
|
||||
results.append({
|
||||
"detection_id": int(box["id"]),
|
||||
"box": list(box["box"]),
|
||||
"identity": {
|
||||
"state": "recognized",
|
||||
"sku_id": match.sku_id,
|
||||
"identity_level": "accepted",
|
||||
"confidence": round(match.similarity, 3),
|
||||
"score": round(match.similarity, 3),
|
||||
"candidates": [runner_up] if runner_up else [],
|
||||
"attempts": 1,
|
||||
"method": "gallery",
|
||||
},
|
||||
"identity": identity_payload(decision),
|
||||
})
|
||||
return results
|
||||
|
||||
|
|
|
|||
693
recognization/code/eval_live.py
Normal file
693
recognization/code/eval_live.py
Normal file
|
|
@ -0,0 +1,693 @@
|
|||
"""Replay a recorded walk through LiveSession and count what it actually decided.
|
||||
|
||||
The recording format is FrameRecorder's: DIR/<session>/index.jsonl plus the JPEG
|
||||
bytes the client sent. Optional labels live in labels.json next to that index
|
||||
(or at --labels). A label is {"frame", "box", "sku_id"}.
|
||||
|
||||
Template leakage is refused, not scored. A label whose crop matches a catalog
|
||||
template (byte-identical, or IoU >= 0.98 against a same-sized template pasted
|
||||
into the frame) is excluded and counted under leakage. Those crops are how the
|
||||
store was enrolled; scoring them is training-set accuracy.
|
||||
|
||||
python code/eval_live.py RECORDING_DIR [--stock-dir DIR] [--labels PATH]
|
||||
[--json PATH] [--iou 0.5]
|
||||
|
||||
Prints a human count table and writes the same report as JSON (default
|
||||
<recording>/eval_live.json). Missing input exits 2 with {"error": ...} on
|
||||
stdout. The same pinned recording, catalog, weights, and labels produce the
|
||||
same counts; timings are omitted because they are not.
|
||||
|
||||
No held-out labels means denominators and fingerprints only. That is not a
|
||||
walk accuracy number.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
|
||||
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
if current_dir not in sys.path:
|
||||
sys.path.insert(0, current_dir)
|
||||
|
||||
LEVELS = ("accepted", "ambiguous", "rejected", "unknown", "unsettled")
|
||||
TAG_STATES = ("readable", "unreadable", "absent", "detected")
|
||||
|
||||
|
||||
class EvalInputError(ValueError):
|
||||
"""The recording, labels, or catalog cannot be scored as given."""
|
||||
|
||||
|
||||
def file_sha256(path: str) -> Optional[str]:
|
||||
digest = hashlib.sha256()
|
||||
try:
|
||||
with open(path, "rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1 << 20), b""):
|
||||
digest.update(chunk)
|
||||
except OSError:
|
||||
return None
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def content_sha256(payload: bytes) -> str:
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
|
||||
def _iou(a: Sequence[float], b: Sequence[float]) -> float:
|
||||
if len(a) != 4 or len(b) != 4:
|
||||
return 0.0
|
||||
ax1, ay1, ax2, ay2 = (float(v) for v in a)
|
||||
bx1, by1, bx2, by2 = (float(v) for v in b)
|
||||
ix1, iy1 = max(ax1, bx1), max(ay1, by1)
|
||||
ix2, iy2 = min(ax2, bx2), min(ay2, by2)
|
||||
inter = max(0.0, ix2 - ix1) * max(0.0, iy2 - iy1)
|
||||
if inter <= 0.0:
|
||||
return 0.0
|
||||
area_a = max(0.0, ax2 - ax1) * max(0.0, ay2 - ay1)
|
||||
area_b = max(0.0, bx2 - bx1) * max(0.0, by2 - by1)
|
||||
union = area_a + area_b - inter
|
||||
return inter / union if union > 0.0 else 0.0
|
||||
|
||||
|
||||
def load_index(recording_dir: str) -> List[Dict[str, Any]]:
|
||||
path = os.path.join(recording_dir, "index.jsonl")
|
||||
if not os.path.isfile(path):
|
||||
raise EvalInputError(f"missing recording index: {path}")
|
||||
rows: List[Dict[str, Any]] = []
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
for number, line in enumerate(handle, start=1):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise EvalInputError(f"{path}:{number}: {exc.msg}") from exc
|
||||
if not isinstance(entry, dict) or "frame" not in entry or "path" not in entry:
|
||||
raise EvalInputError(f"{path}:{number}: line needs frame and path")
|
||||
rows.append(entry)
|
||||
if not rows:
|
||||
raise EvalInputError(f"empty recording index: {path}")
|
||||
return rows
|
||||
|
||||
|
||||
def recording_fingerprint(recording_dir: str, rows: Sequence[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""Hash the JPEGs the client sent, in index order. Missing files are an error."""
|
||||
digest = hashlib.sha256()
|
||||
missing: List[str] = []
|
||||
for row in rows:
|
||||
name = str(row["path"])
|
||||
path = os.path.join(recording_dir, name)
|
||||
payload_hash = file_sha256(path)
|
||||
if payload_hash is None:
|
||||
missing.append(name)
|
||||
continue
|
||||
digest.update(f"{row['frame']}:{name}:{payload_hash}\n".encode("utf-8"))
|
||||
if missing:
|
||||
raise EvalInputError("missing recorded frames: " + ", ".join(missing[:8]))
|
||||
return {"sha256": digest.hexdigest(), "frames": len(rows)}
|
||||
|
||||
|
||||
def load_labels(path: Optional[str]) -> List[Dict[str, Any]]:
|
||||
if not path:
|
||||
return []
|
||||
if not os.path.isfile(path):
|
||||
raise EvalInputError(f"missing labels: {path}")
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
payload = json.loads(handle.read())
|
||||
except json.JSONDecodeError as exc:
|
||||
raise EvalInputError(f"labels are not JSON: {exc.msg}") from exc
|
||||
rows = payload.get("labels") if isinstance(payload, dict) else payload
|
||||
if not isinstance(rows, list):
|
||||
raise EvalInputError("labels must be a list or {\"labels\": [...]}")
|
||||
cleaned: List[Dict[str, Any]] = []
|
||||
for index, row in enumerate(rows):
|
||||
if not isinstance(row, dict):
|
||||
raise EvalInputError(f"labels[{index}] is not an object")
|
||||
if "frame" not in row or "sku_id" not in row or "box" not in row:
|
||||
raise EvalInputError(f"labels[{index}] needs frame, box, sku_id")
|
||||
box = row["box"]
|
||||
if not isinstance(box, (list, tuple)) or len(box) != 4:
|
||||
raise EvalInputError(f"labels[{index}].box must be [x1, y1, x2, y2]")
|
||||
cleaned.append(
|
||||
{
|
||||
"frame": int(row["frame"]),
|
||||
"box": [int(v) for v in box],
|
||||
"sku_id": str(row["sku_id"]),
|
||||
"source": row.get("source"),
|
||||
}
|
||||
)
|
||||
return cleaned
|
||||
|
||||
|
||||
def catalog_fingerprint(stock_dir: str, stock_csv: str = "stock.csv") -> Dict[str, Any]:
|
||||
"""Same inputs as RecognitionEngine.catalog_version, plus the full digest and head."""
|
||||
digest = hashlib.sha256()
|
||||
csv_path = os.path.join(stock_dir, stock_csv)
|
||||
csv_hash = file_sha256(csv_path)
|
||||
if csv_hash is None:
|
||||
digest.update(b"missing-csv")
|
||||
else:
|
||||
digest.update(bytes.fromhex(csv_hash))
|
||||
try:
|
||||
entries = sorted(
|
||||
(name, os.path.getsize(os.path.join(stock_dir, name)))
|
||||
for name in os.listdir(stock_dir)
|
||||
if name.lower().endswith((".jpeg", ".jpg", ".png"))
|
||||
)
|
||||
except OSError as exc:
|
||||
raise EvalInputError(f"cannot read catalog {stock_dir}: {exc.strerror}") from exc
|
||||
for name, size in entries:
|
||||
digest.update(f"{name}:{size}".encode("utf-8"))
|
||||
full = digest.hexdigest()
|
||||
head_path = os.path.join(stock_dir, "identity_head.pt")
|
||||
return {
|
||||
"stock_dir": os.path.basename(os.path.abspath(stock_dir)),
|
||||
"version": full[:12],
|
||||
"sha256": full,
|
||||
"templates": len(entries),
|
||||
"csv_sha256": csv_hash,
|
||||
"identity_head_sha256": file_sha256(head_path),
|
||||
}
|
||||
|
||||
|
||||
def model_fingerprint(weights_path: str) -> Dict[str, Any]:
|
||||
try:
|
||||
import ultralytics
|
||||
|
||||
library = f"ultralytics-{ultralytics.__version__}"
|
||||
except Exception:
|
||||
library = "ultralytics-unknown"
|
||||
return {
|
||||
"version": f"{os.path.basename(weights_path)}@{library}",
|
||||
"weights_sha256": file_sha256(weights_path) if weights_path else None,
|
||||
}
|
||||
|
||||
|
||||
def _decode_image(payload: bytes) -> Any:
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
image = cv2.imdecode(np.frombuffer(payload, dtype=np.uint8), cv2.IMREAD_COLOR)
|
||||
if image is None:
|
||||
raise EvalInputError("recorded frame is not a decodable image")
|
||||
return image
|
||||
|
||||
|
||||
def template_crops(stock_dir: str) -> List[Tuple[str, Any]]:
|
||||
"""Catalog photos keyed by SKU stem (sku_l05, sku_l05_1, ...)."""
|
||||
import cv2
|
||||
|
||||
crops: List[Tuple[str, Any]] = []
|
||||
try:
|
||||
names = sorted(os.listdir(stock_dir))
|
||||
except OSError:
|
||||
return crops
|
||||
for name in names:
|
||||
stem, extension = os.path.splitext(name)
|
||||
if extension.lower() not in (".jpeg", ".jpg", ".png"):
|
||||
continue
|
||||
image = cv2.imread(os.path.join(stock_dir, name))
|
||||
if image is None or image.size == 0:
|
||||
continue
|
||||
crops.append((stem, image))
|
||||
return crops
|
||||
|
||||
|
||||
def _same_picture(crop: Any, template: Any) -> bool:
|
||||
"""True when two same-sized images are the same photo after JPEG."""
|
||||
import numpy as np
|
||||
|
||||
left = crop.astype("float32").reshape(-1)
|
||||
right = template.astype("float32").reshape(-1)
|
||||
if left.size < 16 or left.size != right.size:
|
||||
return False
|
||||
left = left - float(left.mean())
|
||||
right = right - float(right.mean())
|
||||
denom = float(np.sqrt((left * left).sum() * (right * right).sum()))
|
||||
if denom <= 1e-6:
|
||||
return bool(np.max(np.abs(crop.astype("int16") - template.astype("int16"))) <= 2)
|
||||
return float((left * right).sum() / denom) >= 0.98
|
||||
|
||||
|
||||
def leaked_labels(
|
||||
recording_dir: str,
|
||||
rows: Sequence[Dict[str, Any]],
|
||||
labels: Sequence[Dict[str, Any]],
|
||||
stock_dir: str,
|
||||
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
||||
"""Drop labels whose crop is a catalog template.
|
||||
|
||||
Byte identity catches a frame that is the template file. Geometric identity
|
||||
catches a template pasted into a larger frame at the labelled box.
|
||||
"""
|
||||
if not labels:
|
||||
return [], []
|
||||
templates = template_crops(stock_dir)
|
||||
template_hashes = {}
|
||||
try:
|
||||
names = os.listdir(stock_dir)
|
||||
except OSError:
|
||||
names = []
|
||||
for name in names:
|
||||
digest = file_sha256(os.path.join(stock_dir, name))
|
||||
if digest:
|
||||
template_hashes.setdefault(digest, os.path.splitext(name)[0])
|
||||
by_frame: Dict[int, List[Dict[str, Any]]] = {}
|
||||
for label in labels:
|
||||
by_frame.setdefault(int(label["frame"]), []).append(label)
|
||||
held: List[Dict[str, Any]] = []
|
||||
leaked: List[Dict[str, Any]] = []
|
||||
frames = {int(row["frame"]): row for row in rows}
|
||||
for frame_id, frame_labels in by_frame.items():
|
||||
row = frames.get(frame_id)
|
||||
image = None
|
||||
payload_hash = None
|
||||
if row is not None:
|
||||
path = os.path.join(recording_dir, str(row["path"]))
|
||||
try:
|
||||
with open(path, "rb") as handle:
|
||||
payload = handle.read()
|
||||
except OSError:
|
||||
payload = b""
|
||||
payload_hash = content_sha256(payload) if payload else None
|
||||
if payload:
|
||||
image = _decode_image(payload)
|
||||
for label in frame_labels:
|
||||
reason = None
|
||||
matched = None
|
||||
if payload_hash in template_hashes:
|
||||
reason = "frame_is_template"
|
||||
matched = template_hashes[payload_hash]
|
||||
elif image is not None:
|
||||
x1, y1, x2, y2 = label["box"]
|
||||
crop = image[max(0, y1) : max(0, y2), max(0, x1) : max(0, x2)]
|
||||
if crop.size:
|
||||
import cv2
|
||||
|
||||
ch, cw = crop.shape[:2]
|
||||
for stem, template in templates:
|
||||
th, tw = template.shape[:2]
|
||||
if abs(th - ch) > 1 or abs(tw - cw) > 1:
|
||||
continue
|
||||
resized = template if (th, tw) == (ch, cw) else cv2.resize(template, (cw, ch))
|
||||
# JPEG re-encoding keeps layout and drops exact pixels.
|
||||
if _same_picture(crop, resized):
|
||||
reason = "crop_is_template"
|
||||
matched = stem
|
||||
break
|
||||
if reason:
|
||||
leaked.append({**label, "leak": reason, "template": matched})
|
||||
else:
|
||||
held.append(label)
|
||||
return held, leaked
|
||||
|
||||
|
||||
def identity_level(track: Dict[str, Any]) -> str:
|
||||
identity = track.get("identity") or {}
|
||||
level = identity.get("identity_level")
|
||||
if level in LEVELS:
|
||||
return str(level)
|
||||
state = identity.get("state")
|
||||
if state == "recognized":
|
||||
return "accepted"
|
||||
if state == "needs_confirmation":
|
||||
return "ambiguous"
|
||||
if state == "unrecognized":
|
||||
return "rejected"
|
||||
return "unsettled"
|
||||
|
||||
|
||||
def tag_state(track: Dict[str, Any]) -> str:
|
||||
tag = track.get("price_tag") or track.get("tag") or {}
|
||||
state = tag.get("state")
|
||||
if state in TAG_STATES:
|
||||
return str(state)
|
||||
if tag:
|
||||
return "detected"
|
||||
return "absent"
|
||||
|
||||
|
||||
def score_tracks(tracks: Iterable[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""Denominators over settled decisions. Unsettled tracks are not in the rate."""
|
||||
counts = {level: 0 for level in LEVELS}
|
||||
tags = {state: 0 for state in TAG_STATES}
|
||||
for track in tracks:
|
||||
counts[identity_level(track)] += 1
|
||||
tags[tag_state(track)] += 1
|
||||
settled = counts["accepted"] + counts["ambiguous"] + counts["rejected"] + counts["unknown"]
|
||||
return {
|
||||
"tracks": sum(counts.values()),
|
||||
"settled": settled,
|
||||
"accepted": counts["accepted"],
|
||||
"ambiguous": counts["ambiguous"],
|
||||
"rejected": counts["rejected"],
|
||||
"unknown": counts["unknown"],
|
||||
"unsettled": counts["unsettled"],
|
||||
"accepted_rate": _rate(counts["accepted"], settled),
|
||||
"ambiguous_rate": _rate(counts["ambiguous"], settled),
|
||||
"rejected_rate": _rate(counts["rejected"], settled),
|
||||
"tags_detected": tags["detected"] + tags["readable"] + tags["unreadable"],
|
||||
"tags_readable": tags["readable"],
|
||||
"tags_unreadable": tags["unreadable"],
|
||||
"tags_absent": tags["absent"],
|
||||
}
|
||||
|
||||
|
||||
def _rate(numerator: int, denominator: int) -> Optional[float]:
|
||||
if denominator <= 0:
|
||||
return None
|
||||
return round(numerator / denominator, 4)
|
||||
|
||||
|
||||
def track_churn(frames: Sequence[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""How often one track id changes the SKU it is willing to name.
|
||||
|
||||
A change counts only after the id has already named a SKU. Restarting or
|
||||
dropping the label (relabels/restarts) is reported beside that, from the
|
||||
counters LiveSession already publishes.
|
||||
"""
|
||||
previous: Dict[int, Optional[str]] = {}
|
||||
changes: Dict[int, int] = {}
|
||||
seen: Dict[int, int] = {}
|
||||
relabels: Dict[int, int] = {}
|
||||
restarts: Dict[int, int] = {}
|
||||
for frame in frames:
|
||||
for track in frame.get("tracks") or []:
|
||||
track_id = track.get("track_id")
|
||||
if track_id is None:
|
||||
continue
|
||||
track_id = int(track_id)
|
||||
seen[track_id] = seen.get(track_id, 0) + 1
|
||||
relabels[track_id] = max(relabels.get(track_id, 0), int(track.get("relabels") or 0))
|
||||
restarts[track_id] = max(restarts.get(track_id, 0), int(track.get("restarts") or 0))
|
||||
named = None
|
||||
if identity_level(track) == "accepted":
|
||||
named = (track.get("identity") or {}).get("sku_id")
|
||||
prior = previous.get(track_id)
|
||||
if prior and named and named != prior:
|
||||
changes[track_id] = changes.get(track_id, 0) + 1
|
||||
if named:
|
||||
previous[track_id] = named
|
||||
churned = sum(1 for count in changes.values() if count)
|
||||
return {
|
||||
"tracks": len(seen),
|
||||
"tracks_with_sku_change": churned,
|
||||
"sku_changes": sum(changes.values()),
|
||||
"relabels": sum(relabels.values()),
|
||||
"restarts": sum(restarts.values()),
|
||||
"per_track": [
|
||||
{
|
||||
"track_id": track_id,
|
||||
"observations": seen[track_id],
|
||||
"sku_changes": changes.get(track_id, 0),
|
||||
"relabels": relabels.get(track_id, 0),
|
||||
"restarts": restarts.get(track_id, 0),
|
||||
}
|
||||
for track_id in sorted(seen)
|
||||
if changes.get(track_id, 0) or relabels.get(track_id, 0) or restarts.get(track_id, 0)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def labelled_top1(
|
||||
frames: Sequence[Dict[str, Any]],
|
||||
labels: Sequence[Dict[str, Any]],
|
||||
*,
|
||||
iou_min: float,
|
||||
) -> Dict[str, Any]:
|
||||
"""Top-1 over held-out labels. Accepted wrong SKU is a miss, not a reject.
|
||||
|
||||
Denominator is labels that overlap a track. A label with no overlapping
|
||||
track is unmatched and stays out of the top-1 rate.
|
||||
"""
|
||||
by_frame: Dict[int, List[Dict[str, Any]]] = {}
|
||||
for frame in frames:
|
||||
by_frame.setdefault(int(frame.get("frame", frame.get("frame_id"))), []).append(frame)
|
||||
correct = wrong = rejected = ambiguous = missed = 0
|
||||
matched = 0
|
||||
for label in labels:
|
||||
observations = by_frame.get(int(label["frame"]), [])
|
||||
best_iou = 0.0
|
||||
best: Optional[Dict[str, Any]] = None
|
||||
for observation in observations:
|
||||
for track in observation.get("tracks") or []:
|
||||
score = _iou(label["box"], track.get("box") or [])
|
||||
if score > best_iou:
|
||||
best_iou = score
|
||||
best = track
|
||||
if best is None or best_iou < iou_min:
|
||||
missed += 1
|
||||
continue
|
||||
matched += 1
|
||||
level = identity_level(best)
|
||||
guess = (best.get("identity") or {}).get("sku_id")
|
||||
if level == "accepted" and guess == label["sku_id"]:
|
||||
correct += 1
|
||||
elif level == "accepted":
|
||||
wrong += 1
|
||||
elif level == "ambiguous":
|
||||
ambiguous += 1
|
||||
else:
|
||||
rejected += 1
|
||||
return {
|
||||
"labels": len(labels),
|
||||
"matched": matched,
|
||||
"unmatched": missed,
|
||||
"correct": correct,
|
||||
"wrong": wrong,
|
||||
"rejected": rejected,
|
||||
"ambiguous": ambiguous,
|
||||
"top1": _rate(correct, matched),
|
||||
"iou_min": iou_min,
|
||||
}
|
||||
|
||||
|
||||
def score_recording(
|
||||
recording_dir: str,
|
||||
*,
|
||||
stock_dir: str,
|
||||
weights_path: str = "",
|
||||
labels_path: Optional[str] = None,
|
||||
iou_min: float = 0.5,
|
||||
live_frames: Optional[Sequence[Dict[str, Any]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Score one session directory. live_frames, when given, are the replayed results.
|
||||
|
||||
Without live_frames the recorded index is what the server already decided.
|
||||
That still produces denominators; it is not a fresh model run.
|
||||
"""
|
||||
recording_dir = os.path.abspath(recording_dir)
|
||||
rows = load_index(recording_dir)
|
||||
sample = recording_fingerprint(recording_dir, rows)
|
||||
labels_file = labels_path or os.path.join(recording_dir, "labels.json")
|
||||
labels = load_labels(labels_file if os.path.isfile(labels_file) or labels_path else None)
|
||||
held, leaked = leaked_labels(recording_dir, rows, labels, stock_dir)
|
||||
source = list(live_frames) if live_frames is not None else rows
|
||||
tracks = [track for frame in source for track in (frame.get("tracks") or [])]
|
||||
decisions = score_tracks(tracks)
|
||||
report = {
|
||||
"recording": recording_dir,
|
||||
"source": "live_replay" if live_frames is not None else "recorded_index",
|
||||
"fingerprints": {
|
||||
"sample": sample,
|
||||
"model": model_fingerprint(weights_path),
|
||||
"catalog": catalog_fingerprint(stock_dir),
|
||||
},
|
||||
"decisions": decisions,
|
||||
"tags": {
|
||||
"detected": decisions["tags_detected"],
|
||||
"readable": decisions["tags_readable"],
|
||||
"unreadable": decisions["tags_unreadable"],
|
||||
"absent": decisions["tags_absent"],
|
||||
},
|
||||
"churn": track_churn(source),
|
||||
"leakage": {"excluded": len(leaked), "labels": leaked},
|
||||
"labelled_top1": labelled_top1(source, held, iou_min=iou_min),
|
||||
"evidence": _evidence(labels, held, leaked, live_frames is not None),
|
||||
}
|
||||
return report
|
||||
|
||||
|
||||
def _evidence(
|
||||
labels: Sequence[Dict[str, Any]],
|
||||
held: Sequence[Dict[str, Any]],
|
||||
leaked: Sequence[Dict[str, Any]],
|
||||
replayed: bool,
|
||||
) -> List[str]:
|
||||
gaps: List[str] = []
|
||||
if not replayed:
|
||||
gaps.append("counts are from the recorded index, not a fresh LiveSession replay")
|
||||
if not labels:
|
||||
gaps.append("no held-out labels; labelled top-1 is not a walk accuracy")
|
||||
elif not held:
|
||||
gaps.append("every label matched a catalog template and was excluded")
|
||||
if leaked:
|
||||
gaps.append(f"{len(leaked)} template-derived label(s) excluded")
|
||||
return gaps
|
||||
|
||||
|
||||
def replay_live(
|
||||
recording_dir: str,
|
||||
engine: Any,
|
||||
*,
|
||||
settings: Optional[Any] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Feed recorded JPEGs through one LiveSession, in index order.
|
||||
|
||||
Classification is asynchronous. Each frame is scored only after its
|
||||
in-flight crops finish, then processed again so the label is on the
|
||||
payload. Without that wait the same recording does not replay twice.
|
||||
"""
|
||||
from live import LiveSession
|
||||
|
||||
rows = load_index(recording_dir)
|
||||
session = LiveSession("eval", engine, settings=settings)
|
||||
session.refresh_catalog()
|
||||
if hasattr(session, "enable_tags"):
|
||||
session.enable_tags()
|
||||
frames: List[Dict[str, Any]] = []
|
||||
for row in rows:
|
||||
path = os.path.join(recording_dir, str(row["path"]))
|
||||
with open(path, "rb") as handle:
|
||||
payload = handle.read()
|
||||
frame_id = int(row["frame"])
|
||||
result = session.process_frame(frame_id, payload)
|
||||
# min_hits is 2, so the first pass only detects. Keep passing the same
|
||||
# bytes until a pass schedules nothing and everything in flight lands.
|
||||
for _ in range(4):
|
||||
_wait_until_settled(session)
|
||||
result = session.process_frame(frame_id, payload)
|
||||
if int((session.snapshot().get("stats") or {}).get("classify_pending") or 0) == 0:
|
||||
break
|
||||
result["frame"] = frame_id
|
||||
frames.append(result)
|
||||
engine_shutdown = getattr(engine, "shutdown", None)
|
||||
if callable(engine_shutdown):
|
||||
engine_shutdown()
|
||||
return frames
|
||||
|
||||
|
||||
def _wait_until_settled(session: Any, timeout: float = 30.0) -> None:
|
||||
import time
|
||||
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
pending = int((session.snapshot().get("stats") or {}).get("classify_pending") or 0)
|
||||
if pending == 0:
|
||||
return
|
||||
time.sleep(0.02)
|
||||
raise EvalInputError("classification did not finish before the replay timeout")
|
||||
|
||||
def format_report(report: Dict[str, Any]) -> str:
|
||||
decisions = report["decisions"]
|
||||
tags = report["tags"]
|
||||
top1 = report["labelled_top1"]
|
||||
churn = report["churn"]
|
||||
fingerprints = report["fingerprints"]
|
||||
lines = [
|
||||
f"source: {report['source']}",
|
||||
(
|
||||
f"decisions: accepted {decisions['accepted']} ambiguous {decisions['ambiguous']} "
|
||||
f"rejected {decisions['rejected']} unknown {decisions['unknown']} "
|
||||
f"unsettled {decisions['unsettled']} settled {decisions['settled']}"
|
||||
),
|
||||
(
|
||||
f"rates: accepted {decisions['accepted_rate']} "
|
||||
f"ambiguous {decisions['ambiguous_rate']} rejected {decisions['rejected_rate']} "
|
||||
f"(denominator settled)"
|
||||
),
|
||||
(
|
||||
f"tags: detected {tags['detected']} readable {tags['readable']} "
|
||||
f"unreadable {tags['unreadable']} absent {tags['absent']}"
|
||||
),
|
||||
(
|
||||
f"churn: tracks {churn['tracks']} sku_changes {churn['sku_changes']} "
|
||||
f"relabels {churn['relabels']} restarts {churn['restarts']}"
|
||||
),
|
||||
(
|
||||
f"labelled top-1: {top1['correct']}/{top1['matched']} = {top1['top1']} "
|
||||
f"wrong {top1['wrong']} unmatched {top1['unmatched']} "
|
||||
f"leakage excluded {report['leakage']['excluded']}"
|
||||
),
|
||||
(
|
||||
f"fingerprints: sample {fingerprints['sample']['sha256'][:12]} "
|
||||
f"model {fingerprints['model']['version']} "
|
||||
f"catalog {fingerprints['catalog']['version']}"
|
||||
),
|
||||
]
|
||||
for gap in report["evidence"]:
|
||||
lines.append(f"gap: {gap}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main(argv: Optional[Sequence[str]] = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Score a recorded live walk.")
|
||||
parser.add_argument("recording", nargs="?", help="session directory with index.jsonl")
|
||||
parser.add_argument("--stock-dir", default="stock")
|
||||
parser.add_argument("--weights", default="")
|
||||
parser.add_argument("--labels", default=None)
|
||||
parser.add_argument("--json", dest="json_path", default=None)
|
||||
parser.add_argument("--iou", type=float, default=0.5)
|
||||
parser.add_argument(
|
||||
"--replay",
|
||||
action="store_true",
|
||||
help="re-run frames through LiveSession (needs the recognition stack)",
|
||||
)
|
||||
parser.add_argument("--method", default="hybrid")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
def fail(message: str) -> int:
|
||||
print(json.dumps({"error": message}, ensure_ascii=False))
|
||||
print(f"error: {message}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if not args.recording:
|
||||
return fail("recording directory is required")
|
||||
recording = os.path.abspath(args.recording)
|
||||
if not os.path.isdir(recording):
|
||||
return fail(f"recording directory not found: {recording}")
|
||||
try:
|
||||
live_frames = None
|
||||
weights = args.weights
|
||||
if args.replay:
|
||||
from engine import EngineSettings, build_default_engine
|
||||
from live import LiveSettings
|
||||
|
||||
settings = EngineSettings(stock_dir=args.stock_dir)
|
||||
if weights:
|
||||
settings.weights_path = weights
|
||||
engine = build_default_engine(settings)
|
||||
weights = engine.settings.weights_path
|
||||
live_frames = replay_live(
|
||||
recording, engine, settings=LiveSettings(method=args.method, tags=True)
|
||||
)
|
||||
report = score_recording(
|
||||
recording,
|
||||
stock_dir=args.stock_dir,
|
||||
weights_path=weights,
|
||||
labels_path=args.labels,
|
||||
iou_min=args.iou,
|
||||
live_frames=live_frames,
|
||||
)
|
||||
except EvalInputError as exc:
|
||||
return fail(str(exc))
|
||||
text = json.dumps(report, ensure_ascii=False, indent=1, sort_keys=True)
|
||||
destination = args.json_path or os.path.join(recording, "eval_live.json")
|
||||
try:
|
||||
with open(destination, "w", encoding="utf-8") as handle:
|
||||
handle.write(text + "\n")
|
||||
except OSError as exc:
|
||||
return fail(f"cannot write {destination}: {exc.strerror}")
|
||||
print(format_report(report))
|
||||
print(f"json: {destination}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
from ultralytics import YOLO
|
||||
|
||||
model = YOLO("/Users/nguyenhongvan/recongnize/runs/detect/train/weights/best.pt")
|
||||
image_path = "/Users/nguyenhongvan/recongnize/data/add/image7.jpg"
|
||||
results = model(image_path)
|
||||
results[0].show()
|
||||
|
|
@ -49,6 +49,88 @@ MILK_SHELF_LABELS = {
|
|||
}
|
||||
|
||||
|
||||
def enrollment_report(stock: str, csv_name: str = "stock.csv") -> dict:
|
||||
"""Catalog versus enrolled views. Does not load a model or write files.
|
||||
|
||||
A SKU with no readable view cannot be recognized. One view is reported, not
|
||||
treated as enough: the measured knee is two views. Duplicate CSV rows and a
|
||||
head whose class list disagrees with the catalog are blockers.
|
||||
"""
|
||||
import csv
|
||||
import re
|
||||
|
||||
from gallery import ProductGallery
|
||||
|
||||
names = []
|
||||
try:
|
||||
names = os.listdir(stock)
|
||||
except OSError:
|
||||
names = []
|
||||
images = [name for name in names if os.path.splitext(name)[1].lower() in (".jpeg", ".jpg", ".png")]
|
||||
rows = []
|
||||
csv_path = os.path.join(stock, csv_name)
|
||||
if os.path.exists(csv_path):
|
||||
with open(csv_path, encoding="utf-8") as handle:
|
||||
rows = list(csv.DictReader(handle))
|
||||
sku_ids = []
|
||||
seen = set()
|
||||
duplicate_rows = []
|
||||
for row in rows:
|
||||
sku_id = (row.get("SKU_ID") or "").strip()
|
||||
if not sku_id:
|
||||
continue
|
||||
if sku_id in seen:
|
||||
duplicate_rows.append(sku_id)
|
||||
seen.add(sku_id)
|
||||
sku_ids.append(sku_id)
|
||||
gallery = ProductGallery(stock, sku_ids or [])
|
||||
views = {sku_id: gallery._views_for(sku_id) for sku_id in dict.fromkeys(sku_ids)}
|
||||
missing = [sku_id for sku_id, paths in views.items() if not paths]
|
||||
single = [sku_id for sku_id, paths in views.items() if len(paths) == 1]
|
||||
claimed = {os.path.basename(path) for paths in views.values() for path in paths}
|
||||
orphans = sorted(name for name in images if name not in claimed)
|
||||
# ponytail: prefix collision check. A view named sku_001 must not also match sku_00.
|
||||
collisions = []
|
||||
for sku_id, paths in views.items():
|
||||
bare = [os.path.basename(path) for path in paths if os.path.splitext(os.path.basename(path))[0] != sku_id
|
||||
and not re.fullmatch(re.escape(sku_id) + r"[_-]\d+", os.path.splitext(os.path.basename(path))[0])]
|
||||
if bare:
|
||||
collisions.append(sku_id)
|
||||
blockers = []
|
||||
if duplicate_rows:
|
||||
blockers.append("duplicate_csv_sku")
|
||||
if missing:
|
||||
blockers.append("sku_without_view")
|
||||
if collisions:
|
||||
blockers.append("view_prefix_collision")
|
||||
head_path = os.path.join(stock, "identity_head.pt")
|
||||
head_note = None
|
||||
if os.path.exists(head_path):
|
||||
head_note = "present_not_opened"
|
||||
# Do not torch.load here: the head can be large, and this report must stay cheap.
|
||||
# Class-list drift is reported when the caller passes class_ids.
|
||||
return {
|
||||
"stock": stock,
|
||||
"sku_ids": list(dict.fromkeys(sku_ids)),
|
||||
"images": len(images),
|
||||
"missing": missing,
|
||||
"single_view": single,
|
||||
"orphans": orphans,
|
||||
"duplicate_rows": duplicate_rows,
|
||||
"collisions": collisions,
|
||||
"blockers": blockers,
|
||||
"head": head_note,
|
||||
}
|
||||
|
||||
|
||||
def enrollment_report_text(report: dict) -> str:
|
||||
return (
|
||||
f"{report['stock']}: {len(report['sku_ids'])} SKUs, {report['images']} images, "
|
||||
f"missing={len(report['missing'])}, single_view={len(report['single_view'])}, "
|
||||
f"orphans={len(report['orphans'])}, blockers={report['blockers'] or 'none'}"
|
||||
)
|
||||
|
||||
|
||||
def build_model(image_size: int = 518):
|
||||
import timm
|
||||
import torch
|
||||
|
|
@ -149,13 +231,13 @@ def train_from_stock(args) -> None:
|
|||
from gallery import ProductGallery
|
||||
|
||||
stock = args.stock
|
||||
gallery = ProductGallery(stock, [])
|
||||
# the SKUs are whatever the stock directory holds
|
||||
skus = sorted({os.path.splitext(name)[0].rsplit("_", 1)[0] if os.path.splitext(name)[0].rsplit("_", 1)[-1].isdigit()
|
||||
else os.path.splitext(name)[0]
|
||||
for name in os.listdir(stock)
|
||||
if os.path.splitext(name)[1].lower() in (".jpeg", ".jpg", ".png")})
|
||||
views = {sku: gallery._views_for(sku) for sku in skus}
|
||||
report = enrollment_report(stock)
|
||||
print(enrollment_report_text(report))
|
||||
if report["blockers"]:
|
||||
print(f"{stock}: not training; enrollment blockers: {', '.join(report['blockers'])}")
|
||||
return
|
||||
gallery = ProductGallery(stock, report["sku_ids"])
|
||||
views = {sku: gallery._views_for(sku) for sku in report["sku_ids"]}
|
||||
views = {sku: paths for sku, paths in views.items() if paths}
|
||||
if len(views) < 2:
|
||||
print(f"{stock}: needs at least two products with views, found {len(views)}")
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ on the GPU. That is why this can sit in the live loop at all.
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||
|
|
@ -93,23 +94,39 @@ class ProductGallery:
|
|||
|
||||
# ------------------------------------------------------------------ loading
|
||||
def load_head(self) -> bool:
|
||||
"""Load the store's fine-tuned head, if it has one."""
|
||||
"""Load the store's fine-tuned head, if it has one.
|
||||
|
||||
weights_only: the file is tensors and strings, not executable code.
|
||||
A payload that is not that head leaves the raw gallery in place.
|
||||
"""
|
||||
import torch
|
||||
|
||||
if not os.path.exists(self._head_path):
|
||||
return False
|
||||
try:
|
||||
payload = torch.load(self._head_path, map_location="cpu", weights_only=False)
|
||||
payload = torch.load(self._head_path, map_location="cpu", weights_only=True)
|
||||
if not isinstance(payload, dict):
|
||||
return False
|
||||
projection_state = payload.get("projection")
|
||||
prototypes = payload.get("prototypes")
|
||||
class_ids = payload.get("class_ids")
|
||||
if not isinstance(projection_state, dict) or not torch.is_tensor(prototypes):
|
||||
return False
|
||||
if not isinstance(class_ids, (list, tuple)) or prototypes.ndim != 2:
|
||||
return False
|
||||
if prototypes.shape[0] != len(class_ids):
|
||||
return False
|
||||
projection = torch.nn.Linear(384, 128)
|
||||
projection.load_state_dict(projection_state)
|
||||
projection.eval()
|
||||
self._head = {
|
||||
"projection": projection,
|
||||
"prototypes": torch.nn.functional.normalize(prototypes.float(), dim=1),
|
||||
"class_ids": [str(sku_id) for sku_id in class_ids],
|
||||
}
|
||||
except Exception:
|
||||
self._head = None
|
||||
return False
|
||||
projection = torch.nn.Linear(384, 128)
|
||||
projection.load_state_dict(payload["projection"])
|
||||
projection.eval()
|
||||
self._head = {
|
||||
"projection": projection,
|
||||
"prototypes": torch.nn.functional.normalize(payload["prototypes"].float(), dim=1),
|
||||
"class_ids": list(payload["class_ids"]),
|
||||
}
|
||||
return True
|
||||
|
||||
def _load_model(self) -> Any:
|
||||
|
|
@ -137,8 +154,12 @@ class ProductGallery:
|
|||
return self._available
|
||||
|
||||
def _views_for(self, sku_id: str) -> List[str]:
|
||||
"""Every image in the stock directory that belongs to this SKU."""
|
||||
"""Images whose stem is the SKU or SKU plus _N / -N.
|
||||
|
||||
A bare prefix would enroll sku_001 as a view of sku_00.
|
||||
"""
|
||||
found: List[str] = []
|
||||
extra = re.compile(rf"{re.escape(sku_id)}[_-]\d+$")
|
||||
try:
|
||||
names = sorted(os.listdir(self.stock_dir))
|
||||
except OSError:
|
||||
|
|
@ -147,42 +168,50 @@ class ProductGallery:
|
|||
stem, extension = os.path.splitext(name)
|
||||
if extension.lower() not in (".jpeg", ".jpg", ".png"):
|
||||
continue
|
||||
if stem == sku_id or stem.startswith(f"{sku_id}_") or stem.startswith(f"{sku_id}-"):
|
||||
if stem == sku_id or extra.fullmatch(stem):
|
||||
found.append(os.path.join(self.stock_dir, name))
|
||||
return found
|
||||
|
||||
def load(self) -> bool:
|
||||
"""Embed every enrolled view. Returns False when there is nothing to load."""
|
||||
"""Embed every enrolled view. Returns False when there is nothing to load.
|
||||
|
||||
One lock covers the whole load: two callers cannot each embed the catalog.
|
||||
"""
|
||||
with self._lock:
|
||||
if self._available and self._matrix is not None:
|
||||
return True
|
||||
return self._load_locked()
|
||||
|
||||
def _load_locked(self) -> bool:
|
||||
import cv2
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
paths: List[Tuple[str, str]] = []
|
||||
kept: List[Any] = []
|
||||
for sku_id in self.sku_ids:
|
||||
for path in self._views_for(sku_id):
|
||||
image = cv2.imread(path)
|
||||
if image is None or image.size == 0:
|
||||
continue
|
||||
paths.append((sku_id, path))
|
||||
if not paths:
|
||||
kept.append(_prepare(image, self.image_size))
|
||||
if not kept:
|
||||
self._available = False
|
||||
self._entries = []
|
||||
self._matrix = None
|
||||
return False
|
||||
try:
|
||||
model = self._load_model()
|
||||
tensor = torch.from_numpy(np.stack(kept)).to(self.device)
|
||||
with torch.no_grad():
|
||||
features = model(tensor)
|
||||
features = torch.nn.functional.normalize(features, dim=1).cpu().numpy()
|
||||
except Exception:
|
||||
self._available = False
|
||||
self._entries = []
|
||||
self._matrix = None
|
||||
return False
|
||||
|
||||
batch = []
|
||||
for _, path in paths:
|
||||
image = cv2.imread(path)
|
||||
if image is None or image.size == 0:
|
||||
continue
|
||||
batch.append(_prepare(image, self.image_size))
|
||||
if not batch:
|
||||
self._available = False
|
||||
return False
|
||||
tensor = torch.from_numpy(np.stack(batch)).to(self.device)
|
||||
with torch.no_grad():
|
||||
features = model(tensor)
|
||||
features = torch.nn.functional.normalize(features, dim=1).cpu().numpy()
|
||||
self._entries = [
|
||||
GalleryEntry(sku_id=sku_id, view=path, embedding=features[index])
|
||||
for index, (sku_id, path) in enumerate(paths)
|
||||
|
|
@ -209,13 +238,14 @@ class ProductGallery:
|
|||
if not self._available or self._matrix is None:
|
||||
return GalleryMatch(sku_id=None, similarity=0.0, rejected=True)
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
limit = threshold
|
||||
if limit is None:
|
||||
limit = self.head_threshold if self._head is not None else self.threshold
|
||||
limit = float(limit)
|
||||
embedding = self.embed(crop)
|
||||
if self._head is not None:
|
||||
import torch
|
||||
if self._head is not None:
|
||||
# Two stages, because the two mechanisms fail in opposite directions.
|
||||
# Measured: raw features reject strangers well (5 % false accepts) but
|
||||
|
|
|
|||
|
|
@ -1,92 +0,0 @@
|
|||
"""
|
||||
Native Desktop GUI for Supermarket Shelf Product Recognition
|
||||
Opens a desktop OpenCV window displaying image.jpg with bounding boxes only.
|
||||
Users can click on any product directly on screen to inspect price, stock,
|
||||
and feature matching inliers in real-time.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
if current_dir not in sys.path:
|
||||
sys.path.insert(0, current_dir)
|
||||
|
||||
from pipeline import ShelfRecognitionPipeline
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("STARTING NATIVE DESKTOP INTERFACE")
|
||||
print("Image: image.jpg | Mode: Hybrid (Paper Method)")
|
||||
print("Instructions: Click on ANY product to identify & view info.")
|
||||
print("Press 'q' or 'ESC' on the window to exit.")
|
||||
print("=" * 60)
|
||||
|
||||
pipeline = ShelfRecognitionPipeline()
|
||||
init_res = pipeline.set_active_shelf_image("image.jpg")
|
||||
|
||||
if init_res.get("status") != "success":
|
||||
print("[Error] Failed to load image.jpg")
|
||||
return
|
||||
|
||||
boxes = init_res["boxes"]
|
||||
base_image = pipeline.current_image.copy()
|
||||
|
||||
# Draw ONLY bounding boxes (no class names, no confidence labels)
|
||||
display_image = pipeline.detector.draw_detections(base_image, boxes, draw_labels=False)
|
||||
|
||||
window_name = "Smart Shelf - Click Any Product (Press Q to quit)"
|
||||
cv2.namedWindow(window_name, cv2.WINDOW_NORMAL)
|
||||
cv2.resizeWindow(window_name, 1280, 850)
|
||||
|
||||
def on_mouse(event, x, y, flags, param):
|
||||
if event == cv2.EVENT_LBUTTONDOWN:
|
||||
print(f"\n[Click detected at coordinates: ({x}, {y})]")
|
||||
res = pipeline.process_click(x, y, method="hybrid")
|
||||
|
||||
if res.get("status") == "success":
|
||||
prod = res["inventory"]
|
||||
cls_info = res["classification"]
|
||||
box_info = res["target_box"]
|
||||
|
||||
print("-" * 55)
|
||||
print(f"📦 Product Identified : {prod['product_name']}")
|
||||
print(f"🏷️ SKU ID : {prod['sku_id']}")
|
||||
print(f"💰 Price : ¥{prod['price']}")
|
||||
print(f"📊 In Stock (On-Shelf): {prod['number_on']} units")
|
||||
print(f"🚚 Received / Sold : {prod['number_in']} in / {prod['number_out']} out")
|
||||
print(f"📅 Expiration Date : {prod['expiration_date']}")
|
||||
print(f"🎯 Match Confidence : {cls_info['confidence'] * 100:.1f}% (Score: {cls_info['score']})")
|
||||
print("-" * 55)
|
||||
|
||||
# Update main window with selected box highlight (in red)
|
||||
selected_display = pipeline.detector.draw_detections(
|
||||
base_image, boxes, selected_box_id=box_info["id"], draw_labels=False
|
||||
)
|
||||
cv2.imshow(window_name, selected_display)
|
||||
|
||||
# Show visual match comparison window if available
|
||||
if res.get("match_visualization") is not None:
|
||||
match_win = "Feature Matcher (Shelf Crop vs Stock Template)"
|
||||
cv2.namedWindow(match_win, cv2.WINDOW_NORMAL)
|
||||
cv2.resizeWindow(match_win, 800, 400)
|
||||
cv2.imshow(match_win, res["match_visualization"])
|
||||
else:
|
||||
print(f"[Info] {res.get('message', 'No product found at coordinates.')}")
|
||||
|
||||
cv2.setMouseCallback(window_name, on_mouse)
|
||||
|
||||
while True:
|
||||
cv2.imshow(window_name, display_image)
|
||||
key = cv2.waitKey(20) & 0xFF
|
||||
if key in [ord('q'), ord('Q'), 27]:
|
||||
break
|
||||
|
||||
cv2.destroyAllWindows()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -25,11 +25,126 @@ def relative_confidence(top_score: float, runner_up_score: float) -> float:
|
|||
return round(min(1.0, top_score / max(1.0, (top_score + runner_up_score * 0.5))), 4)
|
||||
|
||||
|
||||
# The gallery's scale is a cosine similarity, where same-product pairs sit above 0.6
|
||||
# and different products below 0.5 - not an inlier count, which has no such meaning.
|
||||
# Cosine scale (gallery). Same-product pairs were measured above 0.6 and
|
||||
# different products below 0.5. These are not a new calibration.
|
||||
COSINE_ACCEPT = 0.60
|
||||
COSINE_AMBIGUOUS = 0.50
|
||||
COSINE_MARGIN = 0.04
|
||||
VOTE_CAP = 3
|
||||
|
||||
|
||||
def _finite(value: Any) -> Optional[float]:
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if number != number or number in (float("inf"), float("-inf")):
|
||||
return None
|
||||
return number
|
||||
|
||||
|
||||
def aggregate_votes(
|
||||
observations: Sequence[Dict[str, float]],
|
||||
*,
|
||||
cap: int = VOTE_CAP,
|
||||
) -> Dict[str, float]:
|
||||
"""Mean of the newest observations, at most `cap`.
|
||||
|
||||
Older frames are dropped, not averaged in. An empty observation still
|
||||
counts, so a later miss can outvote an earlier weak hit. Inlier and
|
||||
cosine scores must not be mixed in one list.
|
||||
"""
|
||||
window = list(observations)[-max(1, int(cap)):]
|
||||
if not window:
|
||||
return {}
|
||||
totals: Dict[str, float] = {}
|
||||
for scores in window:
|
||||
for sku_id, score in (scores or {}).items():
|
||||
number = _finite(score)
|
||||
if number is None:
|
||||
continue
|
||||
totals[str(sku_id)] = totals.get(str(sku_id), 0.0) + number
|
||||
count = len(window)
|
||||
return {sku_id: total / count for sku_id, total in totals.items()}
|
||||
|
||||
|
||||
def _cosine_decision(
|
||||
sorted_candidates: List[Tuple[str, float]],
|
||||
*,
|
||||
method: str,
|
||||
details: Optional[Dict[str, Any]],
|
||||
accept: float,
|
||||
ambiguous: float,
|
||||
margin_min: float,
|
||||
) -> Dict[str, Any]:
|
||||
top_sku, top_score = sorted_candidates[0]
|
||||
runner_score = sorted_candidates[1][1] if len(sorted_candidates) > 1 else 0.0
|
||||
margin = top_score - runner_score
|
||||
if top_score >= accept and margin >= margin_min:
|
||||
level = IDENTITY_ACCEPTED
|
||||
elif top_score >= ambiguous:
|
||||
level = IDENTITY_AMBIGUOUS
|
||||
else:
|
||||
level = IDENTITY_REJECTED
|
||||
return {
|
||||
"status": level,
|
||||
"method": method,
|
||||
"best_sku": top_sku if level == IDENTITY_ACCEPTED else None,
|
||||
"score": float(top_score),
|
||||
"confidence": float(top_score),
|
||||
"identity_level": level,
|
||||
"ranked_candidates": [
|
||||
{"sku_id": sku, "score": float(value)} for sku, value in sorted_candidates[:3]
|
||||
],
|
||||
"details": details or {},
|
||||
"message": None,
|
||||
}
|
||||
|
||||
|
||||
def decision_for_match(
|
||||
match: Any,
|
||||
*,
|
||||
method: str = "gallery",
|
||||
margin: float = COSINE_MARGIN,
|
||||
) -> Dict[str, Any]:
|
||||
"""Rank one gallery match. Rejection is the gallery's, not a second threshold.
|
||||
|
||||
`match()` already applied the raw 0.55 line or the head's 0.40 line. Re-ranking
|
||||
those scores against COSINE_ACCEPT (0.60) would refuse a head match the gallery
|
||||
accepted. The only extra gate here is the published cosine margin.
|
||||
"""
|
||||
similarity = _finite(getattr(match, "similarity", 0.0)) or 0.0
|
||||
if match is None or getattr(match, "rejected", False) or not getattr(match, "sku_id", None):
|
||||
return {
|
||||
"status": IDENTITY_REJECTED,
|
||||
"method": method,
|
||||
"best_sku": None,
|
||||
"score": similarity,
|
||||
"confidence": similarity,
|
||||
"identity_level": IDENTITY_REJECTED,
|
||||
"ranked_candidates": [],
|
||||
"details": {},
|
||||
"message": "no enrolled product is close enough",
|
||||
}
|
||||
runner = getattr(match, "runner_up", None)
|
||||
runner_score = _finite(getattr(match, "runner_up_similarity", 0.0)) or 0.0
|
||||
gap = similarity - runner_score
|
||||
close = bool(runner) and gap < margin
|
||||
level = IDENTITY_AMBIGUOUS if close else IDENTITY_ACCEPTED
|
||||
ranked = [{"sku_id": str(match.sku_id), "score": similarity}]
|
||||
if runner:
|
||||
ranked.append({"sku_id": str(runner), "score": runner_score})
|
||||
return {
|
||||
"status": level,
|
||||
"method": method,
|
||||
"best_sku": None if close else str(match.sku_id),
|
||||
"score": similarity,
|
||||
"confidence": similarity,
|
||||
"identity_level": level,
|
||||
"ranked_candidates": ranked,
|
||||
"details": {},
|
||||
"message": None,
|
||||
}
|
||||
|
||||
|
||||
def decide_identity(
|
||||
|
|
@ -48,7 +163,20 @@ def decide_identity(
|
|||
unknown. Two close scores are ambiguous. Only a unique, above-threshold
|
||||
score is accepted.
|
||||
"""
|
||||
if not scores:
|
||||
if scale == "cosine":
|
||||
if min_score == DEFAULT_MIN_SCORE:
|
||||
min_score = COSINE_ACCEPT
|
||||
if min_margin == DEFAULT_MIN_MARGIN:
|
||||
min_margin = COSINE_MARGIN
|
||||
if min_confidence == DEFAULT_MIN_CONFIDENCE:
|
||||
min_confidence = COSINE_AMBIGUOUS
|
||||
cleaned: Dict[str, float] = {}
|
||||
for sku_id, score in (scores or {}).items():
|
||||
number = _finite(score)
|
||||
if number is None:
|
||||
continue
|
||||
cleaned[str(sku_id)] = number
|
||||
if not cleaned:
|
||||
return {
|
||||
"status": IDENTITY_REJECTED,
|
||||
"method": method,
|
||||
|
|
@ -62,32 +190,17 @@ def decide_identity(
|
|||
}
|
||||
|
||||
sorted_candidates: List[Tuple[str, float]] = sorted(
|
||||
scores.items(), key=lambda x: x[1], reverse=True
|
||||
cleaned.items(), key=lambda item: item[1], reverse=True
|
||||
)
|
||||
if scale == "cosine":
|
||||
# Thresholds that mean something on a similarity scale.
|
||||
top_sku, top_score = sorted_candidates[0]
|
||||
runner_score = sorted_candidates[1][1] if len(sorted_candidates) > 1 else 0.0
|
||||
margin = top_score - runner_score
|
||||
if top_score >= COSINE_ACCEPT and margin >= COSINE_MARGIN:
|
||||
level = IDENTITY_ACCEPTED
|
||||
elif top_score >= COSINE_AMBIGUOUS:
|
||||
level = IDENTITY_AMBIGUOUS
|
||||
else:
|
||||
level = IDENTITY_REJECTED
|
||||
return {
|
||||
"status": level,
|
||||
"method": method,
|
||||
"best_sku": top_sku if level == IDENTITY_ACCEPTED else None,
|
||||
"score": float(top_score),
|
||||
"confidence": float(top_score),
|
||||
"identity_level": level,
|
||||
"ranked_candidates": [
|
||||
{"sku_id": sku, "score": float(value)} for sku, value in sorted_candidates[:3]
|
||||
],
|
||||
"details": details or {},
|
||||
"message": None,
|
||||
}
|
||||
return _cosine_decision(
|
||||
sorted_candidates,
|
||||
method=method,
|
||||
details=details,
|
||||
accept=min_score,
|
||||
ambiguous=min_confidence,
|
||||
margin_min=min_margin,
|
||||
)
|
||||
top_sku, top_score = sorted_candidates[0]
|
||||
runner_up_sku, runner_up_score = (
|
||||
sorted_candidates[1] if len(sorted_candidates) > 1 else (None, 0.0)
|
||||
|
|
|
|||
|
|
@ -96,8 +96,9 @@ class TrackState:
|
|||
last_attempt_area: int = 0
|
||||
score_sums: Dict[str, float] = field(default_factory=dict)
|
||||
score_n: int = 0
|
||||
identity: Optional[Dict[str, Any]] = None
|
||||
observations: List[Dict[str, float]] = field(default_factory=list)
|
||||
candidates: List[Dict[str, Any]] = field(default_factory=list)
|
||||
identity: Optional[Dict[str, Any]] = None
|
||||
confirmed: bool = False
|
||||
label_box: List[int] = field(default_factory=list)
|
||||
drift_frames: int = 0
|
||||
|
|
@ -184,6 +185,10 @@ class LiveSession:
|
|||
self._catalog_by_sku: Dict[str, Dict[str, Any]] = {}
|
||||
self._catalog_version = ""
|
||||
self._tags = None
|
||||
# Shelf position of this frame's origin. Updated from a content shift
|
||||
# before the session lock; tag cadence keys rows by it. Not a shelf map.
|
||||
self._frame_origin = [0.0, 0.0]
|
||||
self._shift_map = None
|
||||
self._compliance_tracker = None
|
||||
self._last_compliance: Dict[str, Any] = {"violations": 0, "by_rule": {}, "rows": []}
|
||||
self._label_memory: "collections.deque[Dict[str, Any]]" = collections.deque(
|
||||
|
|
@ -256,7 +261,7 @@ class LiveSession:
|
|||
track.identity = None
|
||||
track.candidates = []
|
||||
track.score_sums = {}
|
||||
track.score_n = 0
|
||||
track.observations = []
|
||||
track.attempts = 0
|
||||
track.in_flight = 0
|
||||
track.last_attempt_seq = 0
|
||||
|
|
@ -286,6 +291,8 @@ class LiveSession:
|
|||
self._slots = []
|
||||
self._latest_image = None
|
||||
self._latest_frame_id = None
|
||||
self._frame_origin = [0.0, 0.0]
|
||||
self._shift_map = None
|
||||
|
||||
# ------------------------------------------------------------------ frame path
|
||||
def process_frame(self, frame_id: int, payload: bytes) -> Dict[str, Any]:
|
||||
|
|
@ -300,6 +307,9 @@ class LiveSession:
|
|||
empty_enabled = self.settings.empty_slots
|
||||
generation = self._generation
|
||||
|
||||
|
||||
shelf_offset = self._note_frame_shift(image)
|
||||
|
||||
boxes = self.engine.detect(image, conf=conf)
|
||||
detected_at = time.perf_counter()
|
||||
tracks = self.tracker.update(
|
||||
|
|
@ -327,7 +337,13 @@ class LiveSession:
|
|||
raw_box = list(detection["box"])
|
||||
confidence = float(detection["confidence"])
|
||||
if raw_box is None:
|
||||
raw_box = list(track.box)
|
||||
# Low-score second pass, or a Kalman box with no detection.
|
||||
# Keep the last measured crop. Do not invent one from the prediction.
|
||||
raw_box = list(state.raw_box) if state is not None and state.raw_box else []
|
||||
confidence = float(state.confidence) if state is not None else confidence
|
||||
low_score = bool(getattr(track, "low_score", False)) or (
|
||||
track.det_index is None and raw_box == []
|
||||
)
|
||||
previous_seq = self._last_seen_seq.get(track.track_id)
|
||||
if state is None:
|
||||
# An id that comes back after an absence may be a recycled id:
|
||||
|
|
@ -369,8 +385,12 @@ class LiveSession:
|
|||
state.last_seq = seq
|
||||
self._last_seen_seq[track.track_id] = seq
|
||||
state.box = list(track.box)
|
||||
state.raw_box = raw_box
|
||||
state.confidence = confidence
|
||||
if raw_box and not low_score:
|
||||
state.raw_box = raw_box
|
||||
state.confidence = confidence
|
||||
elif not state.raw_box and raw_box:
|
||||
state.raw_box = raw_box
|
||||
state.confidence = confidence
|
||||
self._drop_stale_label(state)
|
||||
active[track.track_id] = state
|
||||
self._tracks = active
|
||||
|
|
@ -405,7 +425,7 @@ class LiveSession:
|
|||
for state in self._tracks.values()
|
||||
if state.box
|
||||
]
|
||||
tag_bands = self._tags.plan(tag_boxes, image.shape)
|
||||
tag_bands = self._tags.plan(tag_boxes, image.shape, shelf_offset=shelf_offset)
|
||||
|
||||
# Collect the classification work under the lock; submit it after
|
||||
# releasing it. add_done_callback can run the callback inline when the
|
||||
|
|
@ -443,39 +463,44 @@ 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.
|
||||
Only a measured label, still overlapping its own box, may be copied.
|
||||
An inherited label is not a measurement, so it cannot spread to a neighbour.
|
||||
Blur, glare, and a too-far frame skip inheritance: the overlap is not
|
||||
evidence when the frame itself failed the quality gate.
|
||||
"""
|
||||
if not self._label_memory:
|
||||
return
|
||||
if (self._last_quality or {}).get("hint"):
|
||||
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
|
||||
if entry.get("inherited"):
|
||||
continue
|
||||
if _iou(entry["box"], entry.get("label_box") or entry["box"]) < DRIFT_IOU:
|
||||
continue
|
||||
if _iou(track.box, entry["box"]) < LABEL_MEMORY_IOU:
|
||||
continue
|
||||
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,
|
||||
}
|
||||
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."""
|
||||
|
|
@ -484,10 +509,12 @@ class LiveSession:
|
|||
self._label_memory.append(
|
||||
{
|
||||
"box": list(box),
|
||||
"label_box": list(track.label_box or box),
|
||||
"sku_id": track.identity.get("sku_id"),
|
||||
"confidence": track.identity.get("confidence"),
|
||||
"score": track.identity.get("score"),
|
||||
"seq": seq,
|
||||
"inherited": bool(track.identity.get("inherited")),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -516,7 +543,7 @@ class LiveSession:
|
|||
track.identity = None
|
||||
track.candidates = []
|
||||
track.score_sums = {}
|
||||
track.score_n = 0
|
||||
track.observations = []
|
||||
track.attempts = 0
|
||||
track.in_flight = 0
|
||||
track.last_attempt_seq = 0
|
||||
|
|
@ -539,11 +566,16 @@ class LiveSession:
|
|||
return []
|
||||
|
||||
eligible: List[TrackState] = []
|
||||
poor_frame = bool((self._last_quality or {}).get("hint"))
|
||||
for track in self._tracks.values():
|
||||
if track.state != STATE_DETECTED or track.in_flight > 0:
|
||||
continue
|
||||
if poor_frame:
|
||||
continue
|
||||
if track.hits < self.settings.min_hits:
|
||||
continue
|
||||
if len(track.raw_box) != 4:
|
||||
continue
|
||||
x1, y1, x2, y2 = track.raw_box
|
||||
if min(x2 - x1, y2 - y1) < self.settings.min_crop_px:
|
||||
continue
|
||||
|
|
@ -563,9 +595,8 @@ class LiveSession:
|
|||
eligible.append(track)
|
||||
|
||||
# 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()))
|
||||
# are the ones that just entered the frame. Area is only the tie-break.
|
||||
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)
|
||||
|
|
@ -623,13 +654,18 @@ class LiveSession:
|
|||
return
|
||||
track.in_flight = max(0, track.in_flight - 1)
|
||||
track.attempts += 1
|
||||
for sku_id, score in (scores or {}).items():
|
||||
observation = {
|
||||
str(sku_id): float(score) for sku_id, score in (scores or {}).items()
|
||||
}
|
||||
track.observations.append(observation)
|
||||
track.observations = track.observations[-3:]
|
||||
for sku_id, score in observation.items():
|
||||
track.score_sums[sku_id] = track.score_sums.get(sku_id, 0.0) + float(score)
|
||||
if scores:
|
||||
if observation:
|
||||
track.score_n += 1
|
||||
self._classify_times.append(finished_at - submitted_at)
|
||||
|
||||
if not scores:
|
||||
if not any(track.observations):
|
||||
if track.attempts >= self.settings.max_attempts:
|
||||
track.state = STATE_UNRECOGNIZED
|
||||
track.confirmed = True
|
||||
|
|
@ -638,11 +674,9 @@ class LiveSession:
|
|||
track.candidates = []
|
||||
return
|
||||
|
||||
from identity import decide_identity
|
||||
from identity import aggregate_votes, decide_identity
|
||||
|
||||
mean_scores = {
|
||||
sku_id: total / max(1, track.score_n) for sku_id, total in track.score_sums.items()
|
||||
}
|
||||
mean_scores = aggregate_votes(track.observations)
|
||||
decision = decide_identity(
|
||||
mean_scores, method=method, scale=getattr(self.engine, "identity_scale", "inlier")
|
||||
)
|
||||
|
|
@ -838,7 +872,7 @@ class LiveSession:
|
|||
gray = cv2.cvtColor(small, cv2.COLOR_BGR2GRAY)
|
||||
sharpness = float(cv2.Laplacian(gray, cv2.CV_64F).var())
|
||||
glare = float(np.mean(np.all(small > self.settings.glare_pixel, axis=2)))
|
||||
heights = [state.raw_box[3] - state.raw_box[1] for state in tracks.values() if state.raw_box]
|
||||
heights = [state.raw_box[3] - state.raw_box[1] for state in tracks.values() if len(state.raw_box) == 4]
|
||||
median_height = float(np.median(heights)) if heights else 0.0
|
||||
too_far = bool(
|
||||
heights
|
||||
|
|
@ -847,7 +881,8 @@ class LiveSession:
|
|||
or sum(1 for h in heights if h < self.settings.min_crop_px) > len(heights) / 2
|
||||
)
|
||||
)
|
||||
blur = sharpness < self._blur_threshold()
|
||||
# A black frame has Laplacian 0. That is not blur; a real shelf is not black.
|
||||
blur = sharpness < self._blur_threshold() and float(gray.mean()) > 1.0
|
||||
hint = None
|
||||
if glare > self.settings.glare_fraction:
|
||||
hint = "glare"
|
||||
|
|
@ -898,6 +933,24 @@ class LiveSession:
|
|||
self._tags = TagService(ocr_engine, settings=settings or TagSettings())
|
||||
return True
|
||||
|
||||
def _note_frame_shift(self, image: Any) -> Tuple[float, float]:
|
||||
"""Shelf position of this frame's origin. Phase correlation only.
|
||||
|
||||
Runs before the session lock. A failed registration keeps the last origin
|
||||
rather than inventing a jump. The content shift moves the origin the
|
||||
other way, same sign as ShelfMap.
|
||||
"""
|
||||
if self._shift_map is None:
|
||||
from shelf_map import ShelfMap
|
||||
|
||||
self._shift_map = ShelfMap()
|
||||
shift = self._shift_map.register(image)
|
||||
with self._lock:
|
||||
if shift is not None:
|
||||
self._frame_origin[0] -= float(shift[0])
|
||||
self._frame_origin[1] -= float(shift[1])
|
||||
return (self._frame_origin[0], self._frame_origin[1])
|
||||
|
||||
def _run_tag_passes(self, image: Any, bands: Sequence[Any]) -> None:
|
||||
"""Submit tag passes to the engine's tag worker (never the frame loop)."""
|
||||
service = self._tags
|
||||
|
|
|
|||
|
|
@ -124,8 +124,7 @@ def main() -> None:
|
|||
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}"
|
||||
headers = {"Authorization": f"Bearer {args.token}"} if args.token else None
|
||||
|
||||
source = FrameSource(args.source, width=args.width, pan_step=args.pan)
|
||||
rtts: List[float] = []
|
||||
|
|
@ -147,7 +146,7 @@ def main() -> None:
|
|||
in_flight: Dict[int, float] = {}
|
||||
pending_frame: Optional[int] = None
|
||||
|
||||
with connect(url, max_size=8 * 1024 * 1024) as socket:
|
||||
with connect(url, max_size=8 * 1024 * 1024, additional_headers=headers) as socket:
|
||||
hello = json.loads(socket.recv(timeout=10))
|
||||
if hello.get("type") != "hello":
|
||||
raise SystemExit(f"unexpected first message: {hello}")
|
||||
|
|
|
|||
|
|
@ -1,84 +0,0 @@
|
|||
import cv2
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
|
||||
def suft_function(image):
|
||||
image1 = cv2.imread(image)
|
||||
# Convert the training image to RGB
|
||||
training_image = cv2.cvtColor(image1, cv2.COLOR_BGR2RGB)
|
||||
|
||||
# Convert the training image to gray scale
|
||||
training_gray = cv2.cvtColor(training_image, cv2.COLOR_RGB2GRAY)
|
||||
|
||||
# Create test image by adding Scale Invariance and Rotational Invariance
|
||||
test_image = cv2.pyrDown(training_image)
|
||||
test_image = cv2.pyrDown(test_image)
|
||||
num_rows, num_cols = test_image.shape[:2]
|
||||
|
||||
rotation_matrix = cv2.getRotationMatrix2D((num_cols/2, num_rows/2), 30, 1)
|
||||
test_image = cv2.warpAffine(test_image, rotation_matrix, (num_cols, num_rows))
|
||||
|
||||
test_gray = cv2.cvtColor(test_image, cv2.COLOR_RGB2GRAY)
|
||||
|
||||
# Display traning image and testing image
|
||||
fx, plots = plt.subplots(1, 2, figsize=(20,10))
|
||||
|
||||
plots[0].set_title("Training Image")
|
||||
plots[0].imshow(training_image)
|
||||
|
||||
plots[1].set_title("Testing Image")
|
||||
plots[1].imshow(test_image)
|
||||
|
||||
|
||||
# ## Detect keypoints and Create Descriptor
|
||||
|
||||
surf = cv2.xfeatures2d.SURF_create(800)
|
||||
|
||||
train_keypoints, train_descriptor = surf.detectAndCompute(training_gray, None)
|
||||
test_keypoints, test_descriptor = surf.detectAndCompute(test_gray, None)
|
||||
|
||||
keypoints_without_size = np.copy(training_image)
|
||||
keypoints_with_size = np.copy(training_image)
|
||||
|
||||
cv2.drawKeypoints(training_image, train_keypoints, keypoints_without_size, color = (0, 255, 0))
|
||||
|
||||
cv2.drawKeypoints(training_image, train_keypoints, keypoints_with_size, flags = cv2.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS)
|
||||
|
||||
# Display image with and without keypoints size
|
||||
fx, plots = plt.subplots(1, 2, figsize=(20,10))
|
||||
|
||||
plots[0].set_title("Train keypoints With Size")
|
||||
plots[0].imshow(keypoints_with_size, cmap='gray')
|
||||
|
||||
plots[1].set_title("Train keypoints Without Size")
|
||||
plots[1].imshow(keypoints_without_size, cmap='gray')
|
||||
|
||||
# Print the number of keypoints detected in the training image
|
||||
print("Number of Keypoints Detected In The Training Image: ", len(train_keypoints))
|
||||
|
||||
# Print the number of keypoints detected in the query image
|
||||
print("Number of Keypoints Detected In The Query Image: ", len(test_keypoints))
|
||||
|
||||
|
||||
# ## Matching Keypoints
|
||||
|
||||
# Create a Brute Force Matcher object.
|
||||
bf = cv2.BFMatcher(cv2.NORM_L1, crossCheck = False)
|
||||
|
||||
# Perform the matching between the SURF descriptors of the training image and the test image
|
||||
matches = bf.match(train_descriptor, test_descriptor)
|
||||
|
||||
# The matches with shorter distance are the ones we want.
|
||||
matches = sorted(matches, key = lambda x : x.distance)
|
||||
|
||||
result = cv2.drawMatches(training_image, train_keypoints, test_gray, test_keypoints, matches, test_gray, flags = 2)
|
||||
|
||||
# Display the best matching points
|
||||
plt.rcParams['figure.figsize'] = [14.0, 7.0]
|
||||
plt.title('Best Matching Points')
|
||||
plt.imshow(result)
|
||||
plt.show()
|
||||
|
||||
# Print total number of matching points between the training and query images
|
||||
print("\nNumber of Matching Keypoints Between The Training and Query Images: ", len(matches))
|
||||
return keypoints_with_size
|
||||
|
|
@ -29,19 +29,116 @@ from the small one and marked as derived rather than measured.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
|
||||
|
||||
# The consumption tax used by the tags in the demo images. A tag whose small
|
||||
# price is exactly 1.08x its big price is self-consistent; other stores and eras
|
||||
# use 1.10 or a per-100g unit price instead, so this is a check, never a rule.
|
||||
TAX_RATIOS = (1.08, 1.10)
|
||||
# Defaults when a store has no store.json. Measured on the demo LOPIA tags:
|
||||
# 8% consumption tax, brick-red rails, and shelf prices from ¥50. A store file
|
||||
# replaces these for that store only.
|
||||
DEFAULT_TAX_RATIOS = (1.08, 1.10)
|
||||
DEFAULT_MIN_PRICE = 50
|
||||
# OpenCV hue is 0-180. Brick red wraps past 0, so a pixel is rail-coloured when
|
||||
# its hue is below hue_low or above hue_high.
|
||||
DEFAULT_RAIL_HSV = {"hue_low": 14, "hue_high": 166, "sat_min": 70, "val_min": 70}
|
||||
# Aliases the parser reads. load_store_policy() republishes them per store.
|
||||
TAX_RATIOS = DEFAULT_TAX_RATIOS
|
||||
MIN_PRICE = DEFAULT_MIN_PRICE
|
||||
|
||||
_RAIL: Optional["StorePolicy"] = None
|
||||
|
||||
|
||||
def _remember_rail(policy: "StorePolicy") -> None:
|
||||
global _RAIL
|
||||
_RAIL = policy
|
||||
|
||||
|
||||
|
||||
class StorePolicyError(ValueError):
|
||||
"""store.json exists but cannot be used. Never borrow another store's file."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StorePolicy:
|
||||
tax_ratios: Tuple[float, ...] = DEFAULT_TAX_RATIOS
|
||||
min_price: int = DEFAULT_MIN_PRICE
|
||||
rail_hue_low: int = 14
|
||||
rail_hue_high: int = 166
|
||||
rail_sat_min: int = 70
|
||||
rail_val_min: int = 70
|
||||
store_id: Optional[str] = None
|
||||
|
||||
def active(self) -> None:
|
||||
global TAX_RATIOS, MIN_PRICE
|
||||
TAX_RATIOS = self.tax_ratios
|
||||
MIN_PRICE = self.min_price
|
||||
_remember_rail(self)
|
||||
|
||||
def default_policy() -> StorePolicy:
|
||||
policy = StorePolicy()
|
||||
policy.active()
|
||||
return policy
|
||||
|
||||
|
||||
def _as_ratio(value: Any) -> float:
|
||||
number = float(value)
|
||||
if not 1.0 < number <= 1.5:
|
||||
raise StorePolicyError(f"tax ratio {value!r} is outside 1-1.5")
|
||||
return number
|
||||
|
||||
|
||||
def _as_bound(value: Any, name: str, low: int, high: int) -> int:
|
||||
number = int(value)
|
||||
if not low <= number <= high:
|
||||
raise StorePolicyError(f"{name} {value!r} is outside {low}-{high}")
|
||||
return number
|
||||
|
||||
|
||||
def load_store_policy(stock_dir: Optional[str]) -> StorePolicy:
|
||||
"""Read `<stock_dir>/store.json`. A missing file uses the defaults.
|
||||
|
||||
A file that is present but invalid raises StorePolicyError naming that path.
|
||||
"""
|
||||
if not stock_dir:
|
||||
return StorePolicy()
|
||||
path = os.path.join(stock_dir, "store.json")
|
||||
if not os.path.isfile(path):
|
||||
return StorePolicy()
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
raw = json.load(handle)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise StorePolicyError(f"{path}: {exc}") from exc
|
||||
if not isinstance(raw, dict):
|
||||
raise StorePolicyError(f"{path}: expected an object")
|
||||
try:
|
||||
ratios = raw.get("tax_ratios", list(DEFAULT_TAX_RATIOS))
|
||||
if not isinstance(ratios, list) or not ratios:
|
||||
raise StorePolicyError(f"{path}: tax_ratios must be a non-empty list")
|
||||
rail = raw.get("rail_hsv", DEFAULT_RAIL_HSV)
|
||||
if not isinstance(rail, dict):
|
||||
raise StorePolicyError(f"{path}: rail_hsv must be an object")
|
||||
policy = StorePolicy(
|
||||
tax_ratios=tuple(_as_ratio(item) for item in ratios),
|
||||
min_price=_as_bound(raw.get("min_price", DEFAULT_MIN_PRICE), "min_price", 1, 100000),
|
||||
rail_hue_low=_as_bound(rail.get("hue_low", 14), "hue_low", 0, 180),
|
||||
rail_hue_high=_as_bound(rail.get("hue_high", 166), "hue_high", 0, 180),
|
||||
rail_sat_min=_as_bound(rail.get("sat_min", 70), "sat_min", 0, 255),
|
||||
rail_val_min=_as_bound(rail.get("val_min", 70), "val_min", 0, 255),
|
||||
store_id=str(raw["store_id"]) if raw.get("store_id") else None,
|
||||
)
|
||||
except (TypeError, ValueError) as exc:
|
||||
if isinstance(exc, StorePolicyError):
|
||||
raise
|
||||
raise StorePolicyError(f"{path}: {exc}") from exc
|
||||
policy.active()
|
||||
return policy
|
||||
|
||||
HEADER_PATTERN = re.compile(r"お買得|お買い得|特価|価格|セール")
|
||||
# A shelf price below this is not a price (measured live reads included 20 and 23).
|
||||
MIN_PRICE = 50
|
||||
|
||||
# The tag truncates its discount to a tenth of a 割, so agreement is one step.
|
||||
DISCOUNT_TOLERANCE = 0.05
|
||||
# A regular price above this multiple of the sale price is a misread, not a discount.
|
||||
|
|
@ -201,22 +298,33 @@ def cluster_rows(lines: Sequence[OcrLine], max_gap: int = 26) -> List[List[OcrLi
|
|||
return rows
|
||||
|
||||
|
||||
def tag_row_score(row: Sequence[OcrLine]) -> float:
|
||||
def tag_row_score(row: Sequence[OcrLine], header_pattern: Any = None) -> float:
|
||||
"""How much a row looks like a line of price tags.
|
||||
|
||||
Tags carry a header, a discount, and a decimal (tax-included) price; product
|
||||
packaging rarely carries all three on one line.
|
||||
packaging rarely carries all three on one line. The header pattern is bound
|
||||
here: a missing module global must not fail the tag pass.
|
||||
"""
|
||||
def pattern(name: str, fallback: str) -> Any:
|
||||
found = globals().get(name)
|
||||
return found if found is not None else re.compile(fallback)
|
||||
|
||||
header = header_pattern if header_pattern is not None else pattern(
|
||||
"HEADER_PATTERN", r"お買得|お買い得|特価|価格|セール"
|
||||
)
|
||||
discount = pattern("DISCOUNT_PATTERN", r"(\d+(?:\.\d)?)\s*割\s*引")
|
||||
decimal = pattern("DECIMAL_PATTERN", r"(\d{1,4})[.,](\d{2})(?!\d)")
|
||||
jan = pattern("JAN3_PATTERN", r"(?<!\d)(\d{3})(?!\d)")
|
||||
score = 0.0
|
||||
for line in row:
|
||||
text = line.text
|
||||
if HEADER_PATTERN.search(text):
|
||||
if header.search(text):
|
||||
score += 2.0
|
||||
if DISCOUNT_PATTERN.search(text):
|
||||
if discount.search(text):
|
||||
score += 1.5
|
||||
if DECIMAL_PATTERN.search(text):
|
||||
if decimal.search(text):
|
||||
score += 1.5
|
||||
if JAN3_PATTERN.search(text) and not DECIMAL_PATTERN.search(text):
|
||||
if jan.search(text) and not decimal.search(text):
|
||||
score += 0.5
|
||||
return score
|
||||
|
||||
|
|
@ -448,7 +556,6 @@ def parse_tag_fields(lines: Sequence[OcrLine]) -> TagFields:
|
|||
name_candidates.sort(key=lambda item: (-len(item[0]), -item[1].confidence))
|
||||
fields.name = name_candidates[0][0]
|
||||
|
||||
# Layout, not digit count, separates the price from the JAN: the price is the
|
||||
# LARGEST text on the tag (its line is roughly twice as tall as the small
|
||||
# print), and the JAN's last three digits sit on a small line of their own.
|
||||
# Getting this order wrong made a 269 price look like a JAN and disappear.
|
||||
|
|
@ -534,7 +641,12 @@ def find_rails(image: Any, min_fraction: float = 0.25) -> List[Tuple[int, int, i
|
|||
|
||||
hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
|
||||
hue, sat, val = cv2.split(hsv)
|
||||
brick = (((hue < 14) | (hue > 166)) & (sat > 70) & (val > 70)).astype(np.uint8)
|
||||
rail = _RAIL or StorePolicy()
|
||||
brick = (
|
||||
((hue < rail.rail_hue_low) | (hue > rail.rail_hue_high))
|
||||
& (sat > rail.rail_sat_min)
|
||||
& (val > rail.rail_val_min)
|
||||
).astype(np.uint8)
|
||||
row_fraction = brick.mean(axis=1)
|
||||
height, width = image.shape[:2]
|
||||
rails: List[Tuple[int, int, int, int]] = []
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ from ocr import OcrEngine, OcrLine, OcrResult, OcrUnavailable # noqa: E402
|
|||
from tag_cards import ( # noqa: E402
|
||||
CORROBORATION_FLAGS,
|
||||
MIN_PRICE,
|
||||
StorePolicy,
|
||||
TagFields as TagFieldsModel,
|
||||
corroborate,
|
||||
best_tag_rows,
|
||||
|
|
@ -49,6 +50,13 @@ from tag_cards import ( # noqa: E402
|
|||
TAG_STATE_READABLE = "readable"
|
||||
TAG_STATE_UNREADABLE = "unreadable"
|
||||
TAG_STATE_ABSENT = "absent"
|
||||
# Seen on the rail, OCR returned nothing. Not the same as absent (no tag) or
|
||||
# unreadable (text came back but did not corroborate).
|
||||
TAG_STATE_DETECTED = "detected"
|
||||
|
||||
# Two shelf rows closer than this are the same rail. A screen row index is not
|
||||
# an identity: panning a new shelf into view reuses row 0.
|
||||
SHELF_ROW_BIN_PX = 80
|
||||
|
||||
# Measured tag shape (add/image6.jpg): ~300x80 px, aspect ~3.75:1, digits ~22 px.
|
||||
TAG_MIN_WIDTH_RATIO = 0.035
|
||||
|
|
@ -114,6 +122,7 @@ class TagRecord:
|
|||
tag_id: str
|
||||
box: List[int]
|
||||
band_index: int
|
||||
shelf_key: Tuple[str, int] = ("screen", 0)
|
||||
observations: List[TagObservation] = field(default_factory=list)
|
||||
first_seen: float = 0.0
|
||||
last_seen: float = 0.0
|
||||
|
|
@ -126,6 +135,7 @@ class TagRecord:
|
|||
discount: Optional[float] = None
|
||||
unit_price: Optional[float] = None
|
||||
name: Optional[str] = None
|
||||
price_source: str = "none"
|
||||
flags: List[str] = field(default_factory=list)
|
||||
confidence: float = 0.0
|
||||
|
||||
|
|
@ -135,17 +145,18 @@ class TagRecord:
|
|||
|
||||
Measured live: without this, single uncorroborated reads of the stylised
|
||||
digits were published as readable tags with prices of 20, 23, 72 and 4508.
|
||||
A block seen with no text is `detected`; text that does not parse is
|
||||
`unreadable`. Neither is `absent`.
|
||||
"""
|
||||
if not self.observations:
|
||||
return TAG_STATE_UNREADABLE
|
||||
if self.price is None or self.price < MIN_PRICE:
|
||||
if self.price is None:
|
||||
saw_text = any(observation.lines for observation in self.observations)
|
||||
return TAG_STATE_UNREADABLE if saw_text else TAG_STATE_DETECTED
|
||||
if self.price < MIN_PRICE:
|
||||
return TAG_STATE_UNREADABLE
|
||||
corroborated = bool(
|
||||
{"tax_consistent", "tax_pair_consistent", "discount_consistent"} & set(self.flags)
|
||||
)
|
||||
if not corroborated:
|
||||
return TAG_STATE_UNREADABLE
|
||||
if self.regular_price is None and self.name is None:
|
||||
if not corroborated or (self.regular_price is None and self.name is None):
|
||||
return TAG_STATE_UNREADABLE
|
||||
return TAG_STATE_READABLE
|
||||
|
||||
|
|
@ -159,8 +170,6 @@ class TagRecord:
|
|||
"discount": self.discount,
|
||||
"unit_price": self.unit_price,
|
||||
"name": self.name,
|
||||
"regular_price": self.regular_price,
|
||||
"price_source": self.price_source,
|
||||
"flags": list(self.flags),
|
||||
"confidence": round(float(self.confidence), 3),
|
||||
"observations": len(self.observations),
|
||||
|
|
@ -386,6 +395,20 @@ class TagSettings:
|
|||
settle_passes: int = SETTLE_PASSES
|
||||
min_band_width_ratio: float = 0.10
|
||||
max_tags: int = 120
|
||||
policy: Optional[StorePolicy] = None
|
||||
|
||||
|
||||
def band_key(band: RailBand, shelf_offset: Optional[Sequence[float]] = None) -> Tuple[str, int]:
|
||||
"""Which rail this band is, stable across a pan.
|
||||
|
||||
No shelf origin: the screen row, which is all a still has. With one (the
|
||||
shelf position of the frame origin), the row's shelf y, so a new shelf that
|
||||
lands on screen row 0 is not the row that already settled.
|
||||
"""
|
||||
if not shelf_offset or len(shelf_offset) < 2:
|
||||
return ("screen", int(band.row_index))
|
||||
shelf_y = (band.box[1] + band.box[3]) / 2.0 + float(shelf_offset[1])
|
||||
return ("shelf", int(round(shelf_y / SHELF_ROW_BIN_PX)))
|
||||
|
||||
|
||||
class TagService:
|
||||
|
|
@ -410,7 +433,7 @@ class TagService:
|
|||
self._tags: Dict[str, TagRecord] = {}
|
||||
self._pass_id = 0
|
||||
self._frame_seq = 0
|
||||
self._last_pass_seq: Dict[int, int] = {}
|
||||
self._last_pass_seq: Dict[Tuple[str, int], int] = {}
|
||||
self._pending_bands: set = set()
|
||||
self.passes_run = 0
|
||||
self.passes_failed = 0
|
||||
|
|
@ -418,29 +441,37 @@ class TagService:
|
|||
self.passes_skipped = 0
|
||||
|
||||
# ------------------------------------------------------------------ frame loop
|
||||
def plan(self, boxes: Sequence[Dict[str, Any]], image_shape: Sequence[int]) -> List[RailBand]:
|
||||
def plan(
|
||||
self,
|
||||
boxes: Sequence[Dict[str, Any]],
|
||||
image_shape: Sequence[int],
|
||||
shelf_offset: Optional[Sequence[float]] = None,
|
||||
) -> List[RailBand]:
|
||||
"""Bands worth OCR-ing right now (must be called from the frame loop).
|
||||
|
||||
A band is a shelf row, so its key is the row - not its on-screen position.
|
||||
Keying by position meant a walking camera minted a fresh key every few
|
||||
pixels of pan and re-read the same shelf continuously, which starved
|
||||
classification of CPU (measured: 10x slower per crop while OCR ran).
|
||||
The key is the shelf row when the caller passes the frame origin, and the
|
||||
screen row otherwise. Keying by on-screen pixels re-read the same shelf
|
||||
on every pan step (measured: 10x slower per crop while OCR ran); keying
|
||||
by the screen row index instead hides a new shelf that reuses row 0.
|
||||
"""
|
||||
if not self.settings.enabled or not self.engine.available():
|
||||
return []
|
||||
if self.settings.policy is not None:
|
||||
self.settings.policy.active()
|
||||
bands = rail_bands(boxes, image_shape)
|
||||
width = float(image_shape[1])
|
||||
bands = [band for band in bands if (band.box[2] - band.box[0]) >= self.settings.min_band_width_ratio * width]
|
||||
with self._lock:
|
||||
self._shelf_offset = shelf_offset
|
||||
self._frame_seq += 1
|
||||
seq = self._frame_seq
|
||||
chosen: List[RailBand] = []
|
||||
for band in bands:
|
||||
key = band.row_index
|
||||
key = band_key(band, shelf_offset)
|
||||
if key in self._pending_bands:
|
||||
continue
|
||||
last = self._last_pass_seq.get(key)
|
||||
settled = self._band_settled(band.row_index)
|
||||
settled = self._band_settled(key)
|
||||
if settled and last is not None:
|
||||
continue
|
||||
if last is not None and seq - last < self.settings.pass_interval_frames:
|
||||
|
|
@ -449,8 +480,9 @@ class TagService:
|
|||
if len(chosen) >= self.settings.max_bands_per_pass:
|
||||
break
|
||||
for band in chosen:
|
||||
self._last_pass_seq[band.row_index] = seq
|
||||
self._pending_bands.add(band.row_index)
|
||||
key = band_key(band, shelf_offset)
|
||||
self._last_pass_seq[key] = seq
|
||||
self._pending_bands.add(key)
|
||||
return chosen
|
||||
|
||||
def run_pass(self, image: Any, band: RailBand, scale_bias: float = 1.0) -> List[TagObservation]:
|
||||
|
|
@ -460,7 +492,7 @@ class TagService:
|
|||
independent reads of the same tag are what makes the vote meaningful
|
||||
(identical pixels would produce identical output).
|
||||
"""
|
||||
key = band.row_index
|
||||
key = band_key(band, getattr(self, "_shelf_offset", None))
|
||||
try:
|
||||
x1, y1, x2, y2 = band.box
|
||||
crop = image[y1:y2, x1:x2]
|
||||
|
|
@ -537,7 +569,7 @@ class TagService:
|
|||
)
|
||||
card = card_from_lines(pooled) if pooled else seed
|
||||
card.box = box
|
||||
if not card.fields.is_readable() and card.fields.price is None:
|
||||
if not card.fields.is_readable() and card.fields.price is None and not group:
|
||||
continue
|
||||
cards.append(
|
||||
{
|
||||
|
|
@ -593,8 +625,8 @@ class TagService:
|
|||
)
|
||||
return extra
|
||||
|
||||
def _band_settled(self, row_index: int) -> bool:
|
||||
row_tags = [tag for tag in self._tags.values() if tag.band_index == row_index]
|
||||
def _band_settled(self, key: Tuple[str, int]) -> bool:
|
||||
row_tags = [tag for tag in self._tags.values() if tag.shelf_key == key]
|
||||
if not row_tags:
|
||||
return False
|
||||
return all(tag.settled for tag in row_tags)
|
||||
|
|
@ -604,6 +636,7 @@ class TagService:
|
|||
box = candidate["box"]
|
||||
lines = candidate["lines"]
|
||||
fields = candidate["fields"]
|
||||
shelf_key = band_key(band, getattr(self, "_shelf_offset", None))
|
||||
observation = TagObservation(
|
||||
box=list(box),
|
||||
lines=list(lines),
|
||||
|
|
@ -613,12 +646,13 @@ class TagService:
|
|||
observed_at=time.time(),
|
||||
)
|
||||
|
||||
record = self._match_record(box)
|
||||
record = self._match_record(box, shelf_key)
|
||||
if record is None:
|
||||
record = TagRecord(
|
||||
tag_id=f"tag_{uuid.uuid4().hex[:8]}",
|
||||
box=list(box),
|
||||
band_index=band.row_index,
|
||||
shelf_key=shelf_key,
|
||||
first_seen=observation.observed_at,
|
||||
)
|
||||
if len(self._tags) >= self.settings.max_tags:
|
||||
|
|
@ -636,10 +670,12 @@ class TagService:
|
|||
record.settled = True
|
||||
return observation
|
||||
|
||||
def _match_record(self, box: Sequence[int]) -> Optional[TagRecord]:
|
||||
def _match_record(self, box: Sequence[int], shelf_key: Tuple[str, int]) -> Optional[TagRecord]:
|
||||
best = None
|
||||
best_iou = 0.35
|
||||
for record in self._tags.values():
|
||||
if record.shelf_key != shelf_key:
|
||||
continue
|
||||
iou = _iou(box, record.box)
|
||||
if iou >= best_iou:
|
||||
best_iou = iou
|
||||
|
|
@ -769,7 +805,12 @@ class TagService:
|
|||
"settled": False,
|
||||
}
|
||||
|
||||
counts = {TAG_STATE_READABLE: 0, TAG_STATE_UNREADABLE: 0, TAG_STATE_ABSENT: 0}
|
||||
counts = {
|
||||
TAG_STATE_READABLE: 0,
|
||||
TAG_STATE_UNREADABLE: 0,
|
||||
TAG_STATE_DETECTED: 0,
|
||||
TAG_STATE_ABSENT: 0,
|
||||
}
|
||||
for payload in by_track.values():
|
||||
counts[payload["state"]] = counts.get(payload["state"], 0) + 1
|
||||
return by_track, tags, counts
|
||||
|
|
@ -817,6 +858,7 @@ class TagService:
|
|||
self._pending_bands.clear()
|
||||
self._pass_id = 0
|
||||
self._frame_seq = 0
|
||||
self._shelf_offset = None
|
||||
|
||||
|
||||
def _iou(a: Sequence[int], b: Sequence[int]) -> float:
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
from ultralytics import YOLO
|
||||
import cv2
|
||||
|
||||
model = YOLO("/Users/nguyenhongvan/recongnize/runs/detect/train/weights/best.pt")
|
||||
|
||||
image_path = "/Users/nguyenhongvan/recongnize/data/add/image9.jpg"
|
||||
|
||||
results = model(image_path)
|
||||
|
||||
image = cv2.imread(image_path)
|
||||
|
||||
for box in results[0].boxes.xyxy.cpu().numpy():
|
||||
x1, y1, x2, y2 = map(int, box)
|
||||
|
||||
cv2.rectangle(
|
||||
image,
|
||||
(x1, y1),
|
||||
(x2, y2),
|
||||
(0, 255, 0),
|
||||
2
|
||||
)
|
||||
|
||||
cv2.imshow("Bounding Boxes Only", image)
|
||||
cv2.waitKey(0)
|
||||
cv2.destroyAllWindows()
|
||||
|
|
@ -155,6 +155,47 @@ class RestContractTests(unittest.TestCase):
|
|||
self.assertEqual(response.status_code, 415)
|
||||
self.assertEqual(response.json()["error"]["code"], "UNSUPPORTED_MEDIA_TYPE")
|
||||
|
||||
def test_a_lying_content_length_is_rejected_while_streaming(self):
|
||||
"""The ASGI receive stops once the body passes the cap, before decode."""
|
||||
import anyio
|
||||
|
||||
from transport import BodyLimit
|
||||
|
||||
cap = 32
|
||||
seen = {"bytes": 0}
|
||||
|
||||
async def app(scope, receive, send):
|
||||
while True:
|
||||
message = await receive()
|
||||
if message["type"] != "http.request":
|
||||
continue
|
||||
seen["bytes"] += len(message.get("body") or b"")
|
||||
if not message.get("more_body"):
|
||||
break
|
||||
await send({"type": "http.response.start", "status": 200, "headers": []})
|
||||
await send({"type": "http.response.body", "body": b"ok"})
|
||||
|
||||
chunks = [b"x" * 20, b"y" * 20, b"z" * 20]
|
||||
|
||||
async def receive():
|
||||
body = chunks.pop(0) if chunks else b""
|
||||
return {"type": "http.request", "body": body, "more_body": bool(chunks)}
|
||||
|
||||
sent = []
|
||||
|
||||
async def send(message):
|
||||
sent.append(message)
|
||||
|
||||
async def run():
|
||||
await BodyLimit(app, cap)(
|
||||
{"type": "http"}, receive, send
|
||||
)
|
||||
|
||||
anyio.run(run)
|
||||
self.assertLessEqual(seen["bytes"], cap + 20)
|
||||
self.assertEqual(sent[0]["status"], 413)
|
||||
self.assertIn(b"PAYLOAD_TOO_LARGE", sent[1]["body"])
|
||||
|
||||
def test_classify_and_inspect_roundtrip(self):
|
||||
recognized = self.client.post(
|
||||
"/v1/recognize?classify=false", content=jpeg_bytes(),
|
||||
|
|
@ -285,13 +326,26 @@ class WebSocketProtocolTests(unittest.TestCase):
|
|||
self.assertEqual(missing["code"], "TRACK_NOT_IN_VIEW")
|
||||
|
||||
def test_token_is_enforced_on_the_socket(self):
|
||||
"""Header or the first post-hello auth frame. A query token is rejected."""
|
||||
client, engine = make_app(token="s3cret")
|
||||
try:
|
||||
with self.assertRaises(Exception):
|
||||
with client.websocket_connect("/v1/stream") as socket:
|
||||
self.assertEqual(socket.receive_json()["type"], "hello")
|
||||
socket.send_json({"type": "ping", "t": 1})
|
||||
socket.receive_json()
|
||||
with client.websocket_connect("/v1/stream?token=s3cret") as socket:
|
||||
with self.assertRaises(Exception):
|
||||
with client.websocket_connect("/v1/stream?token=s3cret") as socket:
|
||||
socket.receive_json()
|
||||
with client.websocket_connect(
|
||||
"/v1/stream", headers={"Authorization": "Bearer s3cret"}
|
||||
) as socket:
|
||||
self.assertEqual(socket.receive_json()["type"], "hello")
|
||||
with client.websocket_connect("/v1/stream") as socket:
|
||||
self.assertEqual(socket.receive_json()["type"], "hello")
|
||||
socket.send_json({"type": "auth", "token": "s3cret"})
|
||||
socket.send_json({"type": "ping", "t": 1})
|
||||
self.assertEqual(socket.receive_json(), {"type": "pong", "t": 1})
|
||||
finally:
|
||||
engine.shutdown()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
import sys
|
||||
import os
|
||||
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
if current_dir not in sys.path:
|
||||
sys.path.insert(0, current_dir)
|
||||
|
||||
from empty_shelf_detector import detect_and_draw_empty_slots
|
||||
|
||||
if __name__ == "__main__":
|
||||
image_path = "image.jpg"
|
||||
print(f"Processing empty shelf detection on {image_path}...")
|
||||
annotated, empty_slots = detect_and_draw_empty_slots(
|
||||
image_path,
|
||||
output_path="detected_empty_slots.jpg"
|
||||
)
|
||||
print(f"Detected {len(empty_slots)} empty shelf slots:")
|
||||
for s in empty_slots:
|
||||
print(f" - Slot [{s['id']}]: Row {s['row_index']}, Box={s['box']}, Width={s['width']}px, Est. Missing Items={s['missing_items_est']}")
|
||||
print(f"Visualization saved to: detected_empty_slots.jpg")
|
||||
241
recognization/code/test_eval_live.py
Normal file
241
recognization/code/test_eval_live.py
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
"""Metric contracts for eval_live. No model, no real walk, no fabricated accuracy."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
if current_dir not in sys.path:
|
||||
sys.path.insert(0, current_dir)
|
||||
|
||||
import cv2 # noqa: E402
|
||||
import numpy as np # noqa: E402
|
||||
|
||||
from eval_live import ( # noqa: E402
|
||||
EvalInputError,
|
||||
identity_level,
|
||||
labelled_top1,
|
||||
leaked_labels,
|
||||
load_index,
|
||||
score_recording,
|
||||
score_tracks,
|
||||
track_churn,
|
||||
)
|
||||
|
||||
|
||||
def track(level, sku=None, track_id=1, box=None, relabels=0, restarts=0, tag=None):
|
||||
identity = {"identity_level": level, "state": "recognized" if level == "accepted" else "unrecognized"}
|
||||
if level == "accepted":
|
||||
identity["sku_id"] = sku
|
||||
row = {
|
||||
"track_id": track_id,
|
||||
"box": box or [0, 0, 10, 10],
|
||||
"identity": identity,
|
||||
"relabels": relabels,
|
||||
"restarts": restarts,
|
||||
}
|
||||
if tag is not None:
|
||||
row["price_tag"] = tag
|
||||
return row
|
||||
|
||||
|
||||
def write_jpeg(path, image):
|
||||
ok, encoded = cv2.imencode(".jpg", image)
|
||||
assert ok
|
||||
with open(path, "wb") as handle:
|
||||
handle.write(encoded.tobytes())
|
||||
|
||||
|
||||
class DecisionDenominatorTests(unittest.TestCase):
|
||||
def test_settled_excludes_unsettled_and_rates_use_that_denominator(self):
|
||||
scored = score_tracks(
|
||||
[
|
||||
track("accepted", "sku_a"),
|
||||
track("ambiguous"),
|
||||
track("rejected"),
|
||||
track("unknown"),
|
||||
{"identity": {"state": "unconfirmed", "identity_level": None}},
|
||||
]
|
||||
)
|
||||
self.assertEqual(scored["settled"], 4)
|
||||
self.assertEqual(scored["unsettled"], 1)
|
||||
self.assertEqual(scored["accepted_rate"], 0.25)
|
||||
self.assertIsNone(score_tracks([{"identity": {"state": "unconfirmed"}}])["accepted_rate"])
|
||||
|
||||
def test_wire_state_fills_a_missing_level(self):
|
||||
self.assertEqual(
|
||||
identity_level({"identity": {"state": "needs_confirmation"}}), "ambiguous"
|
||||
)
|
||||
self.assertEqual(identity_level({"identity": {"state": "unconfirmed"}}), "unsettled")
|
||||
|
||||
def test_readable_tags_are_counted_apart_from_detected(self):
|
||||
scored = score_tracks(
|
||||
[
|
||||
track("accepted", "sku_a", tag={"state": "readable"}),
|
||||
track("rejected", tag={"state": "unreadable"}),
|
||||
track("rejected", tag={"state": "absent"}),
|
||||
track("rejected"),
|
||||
]
|
||||
)
|
||||
self.assertEqual(scored["tags_readable"], 1)
|
||||
self.assertEqual(scored["tags_detected"], 2)
|
||||
self.assertEqual(scored["tags_absent"], 2)
|
||||
|
||||
|
||||
class Top1AndChurnTests(unittest.TestCase):
|
||||
def test_top1_counts_only_accepted_matches_over_overlapping_labels(self):
|
||||
frames = [
|
||||
{
|
||||
"frame": 1,
|
||||
"tracks": [
|
||||
track("accepted", "sku_a", box=[0, 0, 10, 10]),
|
||||
track("accepted", "sku_b", track_id=2, box=[100, 100, 110, 110]),
|
||||
],
|
||||
},
|
||||
{"frame": 2, "tracks": [track("rejected", box=[0, 0, 10, 10])]},
|
||||
{"frame": 3, "tracks": []},
|
||||
]
|
||||
labels = [
|
||||
{"frame": 1, "box": [0, 0, 10, 10], "sku_id": "sku_a"},
|
||||
{"frame": 1, "box": [100, 100, 110, 110], "sku_id": "sku_a"},
|
||||
{"frame": 2, "box": [0, 0, 10, 10], "sku_id": "sku_a"},
|
||||
{"frame": 3, "box": [0, 0, 10, 10], "sku_id": "sku_a"},
|
||||
]
|
||||
scored = labelled_top1(frames, labels, iou_min=0.5)
|
||||
self.assertEqual((scored["correct"], scored["wrong"], scored["rejected"], scored["unmatched"]), (1, 1, 1, 1))
|
||||
self.assertEqual(scored["top1"], 0.3333)
|
||||
|
||||
def test_sku_churn_counts_a_renamed_track_not_the_first_name(self):
|
||||
frames = [
|
||||
{"tracks": [track("accepted", "sku_a", track_id=7)]},
|
||||
{"tracks": [track("accepted", "sku_a", track_id=7)]},
|
||||
{"tracks": [track("accepted", "sku_b", track_id=7, relabels=1)]},
|
||||
{"tracks": [track("rejected", track_id=8, restarts=2)]},
|
||||
]
|
||||
churn = track_churn(frames)
|
||||
self.assertEqual(churn["sku_changes"], 1)
|
||||
self.assertEqual(churn["tracks_with_sku_change"], 1)
|
||||
self.assertEqual(churn["relabels"], 1)
|
||||
self.assertEqual(churn["restarts"], 2)
|
||||
self.assertEqual(churn["per_track"][0]["track_id"], 7)
|
||||
|
||||
|
||||
class LeakageTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.root = tempfile.mkdtemp()
|
||||
self.addCleanup(shutil.rmtree, self.root, ignore_errors=True)
|
||||
|
||||
def test_a_pasted_catalog_crop_is_excluded_and_a_real_facing_is_not(self):
|
||||
stock = os.path.join(self.root, "stock")
|
||||
os.makedirs(stock)
|
||||
template = np.full((20, 16, 3), 40, dtype=np.uint8)
|
||||
template[2:8, 2:8] = (255, 0, 0)
|
||||
write_jpeg(os.path.join(stock, "sku_a.jpeg"), template)
|
||||
frame = np.zeros((40, 40, 3), dtype=np.uint8)
|
||||
frame[5:25, 10:26] = template
|
||||
recording = os.path.join(self.root, "walk")
|
||||
os.makedirs(recording)
|
||||
write_jpeg(os.path.join(recording, "000000.jpg"), frame)
|
||||
with open(os.path.join(recording, "index.jsonl"), "w", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps({"frame": 0, "path": "000000.jpg", "tracks": []}) + "\n")
|
||||
labels = [
|
||||
{"frame": 0, "box": [10, 5, 26, 25], "sku_id": "sku_a"},
|
||||
{"frame": 0, "box": [0, 0, 8, 8], "sku_id": "sku_b"},
|
||||
]
|
||||
held, leaked = leaked_labels(recording, load_index(recording), labels, stock)
|
||||
self.assertEqual(len(leaked), 1)
|
||||
self.assertEqual(leaked[0]["leak"], "crop_is_template")
|
||||
self.assertEqual([row["sku_id"] for row in held], ["sku_b"])
|
||||
|
||||
def test_score_recording_names_the_leak_and_refuses_it_as_top1(self):
|
||||
stock = os.path.join(self.root, "stock")
|
||||
os.makedirs(stock)
|
||||
image = np.full((12, 12, 3), 9, dtype=np.uint8)
|
||||
write_jpeg(os.path.join(stock, "sku_a.jpg"), image)
|
||||
with open(os.path.join(stock, "stock.csv"), "w", encoding="utf-8") as handle:
|
||||
handle.write("SKU_ID,product_name\nsku_a,A\n")
|
||||
recording = os.path.join(self.root, "session")
|
||||
os.makedirs(recording)
|
||||
shutil.copy(os.path.join(stock, "sku_a.jpg"), os.path.join(recording, "000000.jpg"))
|
||||
with open(os.path.join(recording, "index.jsonl"), "w", encoding="utf-8") as handle:
|
||||
handle.write(
|
||||
json.dumps(
|
||||
{
|
||||
"frame": 1,
|
||||
"path": "000000.jpg",
|
||||
"tracks": [track("accepted", "sku_a", box=[0, 0, 12, 12])],
|
||||
}
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
with open(os.path.join(recording, "labels.json"), "w", encoding="utf-8") as handle:
|
||||
json.dump({"labels": [{"frame": 1, "box": [0, 0, 12, 12], "sku_id": "sku_a"}]}, handle)
|
||||
report = score_recording(recording, stock_dir=stock)
|
||||
self.assertEqual(report["leakage"]["excluded"], 1)
|
||||
self.assertEqual(report["labelled_top1"]["labels"], 0)
|
||||
self.assertEqual(report["labelled_top1"]["top1"], None)
|
||||
self.assertEqual(report["decisions"]["accepted"], 1)
|
||||
self.assertTrue(report["fingerprints"]["sample"]["sha256"])
|
||||
self.assertEqual(len(report["fingerprints"]["catalog"]["version"]), 12)
|
||||
self.assertTrue(any("template-derived" in gap for gap in report["evidence"]))
|
||||
|
||||
|
||||
class CliTests(unittest.TestCase):
|
||||
def test_missing_recording_is_an_explicit_error(self):
|
||||
completed = subprocess.run(
|
||||
[sys.executable, os.path.join(current_dir, "eval_live.py"), "/no/such/recording"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(completed.returncode, 2)
|
||||
self.assertIn("error", json.loads(completed.stdout))
|
||||
|
||||
def test_the_same_pinned_recording_scores_twice_the_same(self):
|
||||
root = tempfile.mkdtemp()
|
||||
self.addCleanup(shutil.rmtree, root, ignore_errors=True)
|
||||
stock = os.path.join(root, "stock")
|
||||
os.makedirs(stock)
|
||||
with open(os.path.join(stock, "stock.csv"), "w", encoding="utf-8") as handle:
|
||||
handle.write("SKU_ID\nsku_a\n")
|
||||
recording = os.path.join(root, "session")
|
||||
os.makedirs(recording)
|
||||
payload = np.zeros((8, 8, 3), dtype=np.uint8)
|
||||
write_jpeg(os.path.join(recording, "000000.jpg"), payload)
|
||||
line = {
|
||||
"frame": 4,
|
||||
"path": "000000.jpg",
|
||||
"tracks": [
|
||||
track("accepted", "sku_a", tag={"state": "readable"}),
|
||||
track("ambiguous", track_id=2),
|
||||
],
|
||||
}
|
||||
with open(os.path.join(recording, "index.jsonl"), "w", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(line) + "\n")
|
||||
with open(os.path.join(recording, "labels.json"), "w", encoding="utf-8") as handle:
|
||||
json.dump({"labels": [{"frame": 4, "box": [0, 0, 8, 8], "sku_id": "sku_a"}]}, handle)
|
||||
first = score_recording(recording, stock_dir=stock)
|
||||
second = score_recording(recording, stock_dir=stock)
|
||||
self.assertEqual(first, second)
|
||||
self.assertEqual(first["decisions"]["accepted"], 1)
|
||||
self.assertEqual(first["decisions"]["ambiguous"], 1)
|
||||
self.assertEqual(first["labelled_top1"]["correct"], 1)
|
||||
self.assertIsNone(first["fingerprints"]["model"]["weights_sha256"])
|
||||
|
||||
def test_a_broken_index_line_is_an_input_error(self):
|
||||
root = tempfile.mkdtemp()
|
||||
self.addCleanup(shutil.rmtree, root, ignore_errors=True)
|
||||
with open(os.path.join(root, "index.jsonl"), "w", encoding="utf-8") as handle:
|
||||
handle.write("{not json}\n")
|
||||
with self.assertRaises(EvalInputError):
|
||||
load_index(root)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
"""
|
||||
Test Script for image.jpg
|
||||
1. Runs YOLO detection on image.jpg.
|
||||
2. Draws ONLY bounding box rectangles (no class names, no confidence labels).
|
||||
3. Saves output to detected_image.jpg.
|
||||
4. Matches the detected bounding boxes against stock templates.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
if current_dir not in sys.path:
|
||||
sys.path.insert(0, current_dir)
|
||||
|
||||
from detector import ShelfProductDetector
|
||||
from feature_matcher import ProductFeatureMatcher
|
||||
from inventory import InventoryManager
|
||||
|
||||
|
||||
def main():
|
||||
image_path = "image.jpg"
|
||||
print(f"[1/4] Loading {image_path}...")
|
||||
image = cv2.imread(image_path)
|
||||
if image is None:
|
||||
print(f"[Error] Failed to read {image_path}")
|
||||
return
|
||||
|
||||
h, w = image.shape[:2]
|
||||
print(f" Image resolution: {w}x{h}")
|
||||
|
||||
print("[2/4] Detecting products using YOLO...")
|
||||
detector = ShelfProductDetector("runs/detect/train/weights/best.pt")
|
||||
# You can adjust conf_threshold as needed (default 0.25)
|
||||
det_res = detector.detect(image, conf_threshold=0.25)
|
||||
boxes = det_res.get("boxes", [])
|
||||
print(f" Total detected items: {len(boxes)}")
|
||||
|
||||
print("[3/4] Drawing bounding boxes ONLY (no class names, no confidence)...")
|
||||
annotated = detector.draw_detections(image, boxes, draw_labels=False)
|
||||
output_path = "detected_image.jpg"
|
||||
cv2.imwrite(output_path, annotated)
|
||||
print(f" Saved detection visualization to: {output_path}")
|
||||
|
||||
print("[4/4] Matching against stock catalogue (SURF, ORB, BRISK, Hybrid)...")
|
||||
matcher = ProductFeatureMatcher("stock")
|
||||
inv = InventoryManager("stock")
|
||||
|
||||
print("\n--- Stock Templates Present in image.jpg ---")
|
||||
matched_skus = set()
|
||||
sample_matches = []
|
||||
|
||||
# Test top detections to find corresponding stock SKUs
|
||||
for b in boxes:
|
||||
crop = detector.crop_roi(image, b["box"])
|
||||
# Use hybrid method from the paper
|
||||
res = matcher.classify_crop(crop, method="hybrid")
|
||||
if res.get("status") == "success":
|
||||
sku_id = res["best_sku"]
|
||||
score = res["score"]
|
||||
conf = res["confidence"]
|
||||
prod = inv.get_product(sku_id)
|
||||
if prod:
|
||||
matched_skus.add(sku_id)
|
||||
sample_matches.append({
|
||||
"box_id": b["id"],
|
||||
"box": b["box"],
|
||||
"sku_id": sku_id,
|
||||
"product_name": prod["product_name"],
|
||||
"price": prod["price"],
|
||||
"number_on": prod["number_on"],
|
||||
"score": score,
|
||||
"confidence": conf
|
||||
})
|
||||
|
||||
# Sort matches by confidence
|
||||
sample_matches.sort(key=lambda x: x["score"], reverse=True)
|
||||
|
||||
print(f"\nSuccessfully matched {len(matched_skus)} distinct SKUs across detected shelf boxes:")
|
||||
for sm in sample_matches[:10]:
|
||||
print(f"Box #{sm['box_id']:<3} -> [{sm['sku_id']}] {sm['product_name']:<30} | Price: ¥{sm['price']:<5} | On-Shelf: {sm['number_on']} | Score: {sm['score']}")
|
||||
|
||||
print("\n[Complete] Tested image.jpg successfully.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -276,6 +276,36 @@ class TagPassIntegrationTests(unittest.TestCase):
|
|||
self.assertNotIn("price_tag", result["tracks"][0])
|
||||
engine.shutdown()
|
||||
|
||||
def test_a_panned_shelf_reusing_the_screen_row_is_planned(self):
|
||||
"""The same screen row on a shelf that has moved is a new rail."""
|
||||
session, engine, _ = self.make_tagged_session()
|
||||
session._tags.settings.pass_interval_frames = 0
|
||||
session._tags.settings.settle_observations = 1
|
||||
session._tags.settings.settle_passes = 1
|
||||
|
||||
class Panned:
|
||||
def __init__(self):
|
||||
self.calls = 0
|
||||
|
||||
def register(self, image):
|
||||
self.calls += 1
|
||||
# Content moved up 400 px, so the frame origin moves down the shelf.
|
||||
return (0.0, -400.0) if self.calls == 2 else (0.0, 0.0)
|
||||
|
||||
session._shift_map = Panned()
|
||||
process(session, 1)
|
||||
time.sleep(0.05)
|
||||
before = session._tags.stats()["passes_run"]
|
||||
self.assertGreaterEqual(before, 1)
|
||||
process(session, 2)
|
||||
time.sleep(0.1)
|
||||
self.assertGreaterEqual(session._frame_origin[1], 400.0)
|
||||
self.assertGreater(
|
||||
session._tags.stats()["passes_run"], before,
|
||||
"a shelf 400 px away reuses screen row 0 and must be read again",
|
||||
)
|
||||
engine.shutdown()
|
||||
|
||||
|
||||
class FramePipelineTests(unittest.TestCase):
|
||||
def test_latest_frame_wins_and_drops_are_counted(self):
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import numpy as np # noqa: E402
|
|||
from ocr import FakeOcr, NullOcr, OcrLine, OcrResult, OcrUnavailable, build_ocr_engine # noqa: E402
|
||||
from tags import ( # noqa: E402
|
||||
TAG_STATE_ABSENT,
|
||||
TAG_STATE_DETECTED,
|
||||
TAG_STATE_READABLE,
|
||||
TAG_STATE_UNREADABLE,
|
||||
TagService,
|
||||
|
|
@ -30,6 +31,8 @@ from tags import ( # noqa: E402
|
|||
parse_price_tag,
|
||||
rail_bands,
|
||||
)
|
||||
from tag_cards import StorePolicy, StorePolicyError, load_store_policy # noqa: E402
|
||||
import tag_cards # noqa: E402
|
||||
|
||||
|
||||
def image(width=1000, height=600):
|
||||
|
|
@ -275,6 +278,108 @@ class TagServiceTests(unittest.TestCase):
|
|||
service.reset()
|
||||
self.assertEqual(service.stats()["tags"], 0)
|
||||
|
||||
def test_a_new_shelf_reusing_row_zero_is_read(self):
|
||||
"""Screen row 0 on a newly panned shelf is not the row that settled."""
|
||||
service, _ = self.make(pass_interval_frames=0, settle_observations=1, settle_passes=1)
|
||||
boxes = [box(100, 100, 200, 300, 0), box(220, 100, 320, 300, 1), box(340, 100, 440, 300, 2)]
|
||||
array = image()
|
||||
for _ in range(3):
|
||||
for band in service.plan(boxes, array.shape, shelf_offset=(0.0, 0.0)):
|
||||
service.run_pass(array, band)
|
||||
self.assertEqual(
|
||||
service.plan(boxes, array.shape, shelf_offset=(0.0, 0.0)),
|
||||
[],
|
||||
"the same shelf row stays settled",
|
||||
)
|
||||
again = service.plan(boxes, array.shape, shelf_offset=(0.0, 400.0))
|
||||
self.assertTrue(again, "a shelf 400 px away reuses the screen row and must be read")
|
||||
|
||||
def test_a_seen_tag_with_no_text_is_detected_not_absent(self):
|
||||
def reader(crop):
|
||||
return []
|
||||
|
||||
service = TagService(FakeOcr(reader=reader), settings=TagSettings(pass_interval_frames=0))
|
||||
array = image()
|
||||
# A bright paper block on a brick rail, under the product row. OCR reads nothing.
|
||||
array[300:420, :] = (40, 40, 180)
|
||||
array[330:390, 120:280] = (235, 235, 235)
|
||||
boxes = [box(100, 100, 200, 300, 0), box(220, 100, 320, 300, 1), box(340, 100, 440, 300, 2)]
|
||||
bands = service.plan(boxes, array.shape)
|
||||
self.assertTrue(bands)
|
||||
service.run_pass(array, bands[0])
|
||||
by_track, tags, counts = service.attach([{"track_id": 0, "box": [100, 100, 200, 300]}])
|
||||
self.assertTrue(tags, "the paper block is a tag even though OCR returned nothing")
|
||||
self.assertEqual(tags[0]["state"], TAG_STATE_DETECTED)
|
||||
self.assertEqual(by_track[0]["state"], TAG_STATE_DETECTED)
|
||||
self.assertEqual(counts[TAG_STATE_DETECTED], 1)
|
||||
self.assertNotEqual(by_track[0]["state"], TAG_STATE_ABSENT)
|
||||
|
||||
def test_a_tag_pass_survives_a_missing_header_pattern(self):
|
||||
"""Evaluation replay died with NameError: HEADER_PATTERN."""
|
||||
import tag_cards
|
||||
from tag_cards import OcrLine as CardLine
|
||||
|
||||
row = [CardLine(text="毎日お買得", box=(0, 0, 40, 16), confidence=0.9)]
|
||||
saved = tag_cards.__dict__.pop("HEADER_PATTERN", None)
|
||||
try:
|
||||
score = tag_cards.tag_row_score(row)
|
||||
finally:
|
||||
if saved is not None:
|
||||
tag_cards.HEADER_PATTERN = saved
|
||||
self.assertGreater(score, 0, "a sale header still marks the row when the global is missing")
|
||||
service, _ = self.make(pass_interval_frames=0)
|
||||
boxes = [box(100, 100, 200, 300, 0), box(220, 100, 320, 300, 1), box(340, 100, 440, 300, 2)]
|
||||
bands = service.plan(boxes, image().shape)
|
||||
self.assertTrue(bands)
|
||||
service.run_pass(image(), bands[0])
|
||||
self.assertGreaterEqual(service.stats()["passes_run"], 1)
|
||||
self.assertIsNone(service.stats()["last_pass_error"])
|
||||
|
||||
|
||||
class StorePolicyTests(unittest.TestCase):
|
||||
def test_a_missing_file_uses_the_defaults(self):
|
||||
policy = load_store_policy(None)
|
||||
policy.active()
|
||||
self.assertEqual(policy.tax_ratios, (1.08, 1.10))
|
||||
self.assertEqual(policy.min_price, 50)
|
||||
self.assertEqual(tag_cards.MIN_PRICE, 50)
|
||||
|
||||
def test_a_store_file_does_not_change_another_store(self):
|
||||
import json
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
good = os.path.join(root, "lopia")
|
||||
bad = os.path.join(root, "other")
|
||||
os.makedirs(good)
|
||||
os.makedirs(bad)
|
||||
with open(os.path.join(good, "store.json"), "w", encoding="utf-8") as handle:
|
||||
json.dump({"store_id": "lopia", "tax_ratios": [1.08], "min_price": 80}, handle)
|
||||
with open(os.path.join(bad, "store.json"), "w", encoding="utf-8") as handle:
|
||||
handle.write("{not json")
|
||||
loaded = load_store_policy(good)
|
||||
self.assertEqual(loaded.min_price, 80)
|
||||
self.assertEqual(tag_cards.MIN_PRICE, 80)
|
||||
with self.assertRaises(StorePolicyError) as raised:
|
||||
load_store_policy(bad)
|
||||
self.assertIn(bad, str(raised.exception))
|
||||
self.assertEqual(tag_cards.MIN_PRICE, 80, "a bad file must not replace the store already loaded")
|
||||
again = load_store_policy(good)
|
||||
self.assertEqual(again.min_price, 80)
|
||||
|
||||
def test_a_ratio_outside_range_fails_clearly(self):
|
||||
import json
|
||||
import tempfile
|
||||
|
||||
load_store_policy(None)
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
with open(os.path.join(root, "store.json"), "w", encoding="utf-8") as handle:
|
||||
json.dump({"tax_ratios": [2.5], "min_price": 999}, handle)
|
||||
with self.assertRaises(StorePolicyError) as raised:
|
||||
load_store_policy(root)
|
||||
self.assertIn("tax ratio", str(raised.exception))
|
||||
self.assertEqual(tag_cards.MIN_PRICE, 50)
|
||||
|
||||
|
||||
class EngineSelectionTests(unittest.TestCase):
|
||||
def test_explicit_none_and_fake(self):
|
||||
|
|
|
|||
|
|
@ -122,16 +122,30 @@ class _SessionByteTrack(BYTETracker):
|
|||
class TrackOut:
|
||||
"""One tracked object for a frame."""
|
||||
|
||||
__slots__ = ("track_id", "box", "score", "det_index")
|
||||
__slots__ = ("track_id", "box", "score", "det_index", "low_score")
|
||||
|
||||
def __init__(self, track_id: int, box: List[int], score: float, det_index: Optional[int]):
|
||||
def __init__(
|
||||
self,
|
||||
track_id: int,
|
||||
box: List[int],
|
||||
score: float,
|
||||
det_index: Optional[int],
|
||||
*,
|
||||
low_score: bool = False,
|
||||
):
|
||||
self.track_id = int(track_id)
|
||||
self.box = [int(v) for v in box]
|
||||
self.score = float(score)
|
||||
self.det_index = None if det_index is None or det_index < 0 else int(det_index)
|
||||
# ByteTrack's second pass keeps an id alive on a low-score box. That box
|
||||
# is not a new measurement: callers must not crop or re-label from it.
|
||||
self.low_score = bool(low_score)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return f"TrackOut(id={self.track_id}, box={self.box}, score={self.score:.3f}, det={self.det_index})"
|
||||
return (
|
||||
f"TrackOut(id={self.track_id}, box={self.box}, score={self.score:.3f}, "
|
||||
f"det={self.det_index}, low={self.low_score})"
|
||||
)
|
||||
|
||||
|
||||
class SessionTracker:
|
||||
|
|
@ -171,14 +185,17 @@ class SessionTracker:
|
|||
outputs: List[TrackOut] = []
|
||||
if rows is None or len(rows) == 0:
|
||||
return outputs
|
||||
high = float(self.args.track_high_thresh)
|
||||
for row in np.asarray(rows):
|
||||
# [x1, y1, x2, y2, track_id, score, cls, idx]
|
||||
score = float(row[5])
|
||||
outputs.append(
|
||||
TrackOut(
|
||||
track_id=int(row[4]),
|
||||
box=[int(round(row[0])), int(round(row[1])), int(round(row[2])), int(round(row[3]))],
|
||||
score=float(row[5]),
|
||||
score=score,
|
||||
det_index=int(row[7]) if len(row) > 7 else None,
|
||||
low_score=score < high,
|
||||
)
|
||||
)
|
||||
return outputs
|
||||
|
|
|
|||
254
recognization/code/transport.py
Normal file
254
recognization/code/transport.py
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
"""
|
||||
Request bounds and the LAN token check.
|
||||
|
||||
A client can declare a tiny Content-Length and then keep writing, so the
|
||||
declared length is a hint and the read itself stops at the cap. The token
|
||||
never travels in a query string: proxies and access logs keep those.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import ipaddress
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
|
||||
|
||||
|
||||
def is_loopback_host(host: Optional[str]) -> bool:
|
||||
text = (host or "").strip().lower()
|
||||
if text.startswith("[") and text.endswith("]"):
|
||||
text = text[1:-1]
|
||||
if "%" in text: # fe80::1%lo0
|
||||
text = text.split("%", 1)[0]
|
||||
if text in LOOPBACK_HOSTS:
|
||||
return True
|
||||
try:
|
||||
return ipaddress.ip_address(text).is_loopback
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def require_token_for_bind(host: Optional[str], token: Optional[str]) -> None:
|
||||
"""A non-loopback bind with no token is reachable by anyone on that network."""
|
||||
if token or is_loopback_host(host):
|
||||
return
|
||||
raise SystemExit(
|
||||
f"refusing to bind {host} without DORAYAKI_API_TOKEN "
|
||||
"(loopback is the only bind that may run with auth off)"
|
||||
)
|
||||
|
||||
|
||||
def token_matches(presented: Optional[str], token: Optional[str]) -> bool:
|
||||
if not presented or not token:
|
||||
return False
|
||||
# compare_digest raises when the lengths differ. Fold the length in so a
|
||||
# short guess neither throws nor returns early.
|
||||
return hmac.compare_digest(
|
||||
len(presented).to_bytes(8, "big") + presented.encode(),
|
||||
len(token).to_bytes(8, "big") + token.encode(),
|
||||
)
|
||||
|
||||
|
||||
def bearer_matches(authorization: Optional[str], token: str) -> bool:
|
||||
if not authorization or not token:
|
||||
return False
|
||||
scheme, _, presented = authorization.partition(" ")
|
||||
if scheme.lower() != "bearer" or not presented or " " in presented:
|
||||
return False
|
||||
return token_matches(presented, token)
|
||||
|
||||
|
||||
def declared_length(header: Optional[str]) -> Optional[int]:
|
||||
if header is None or header == "":
|
||||
return None
|
||||
try:
|
||||
length = int(header)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return length if length >= 0 else None
|
||||
|
||||
|
||||
async def read_bounded(source, max_bytes: int) -> bytes:
|
||||
"""Stop once the cap is passed. The caller rejects the oversize body.
|
||||
|
||||
A request stream is async-iterable. An UploadFile is not: it has read(n).
|
||||
"""
|
||||
out = bytearray()
|
||||
read = getattr(source, "read", None)
|
||||
if read is not None and not hasattr(source, "__aiter__"):
|
||||
while True:
|
||||
chunk = await read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
out += chunk
|
||||
if len(out) > max_bytes:
|
||||
break
|
||||
return bytes(out)
|
||||
async for chunk in source:
|
||||
if not chunk:
|
||||
continue
|
||||
out += chunk
|
||||
if len(out) > max_bytes:
|
||||
break
|
||||
return bytes(out)
|
||||
|
||||
|
||||
class BodyLimit:
|
||||
"""Stop the ASGI receive once the body passes the cap.
|
||||
|
||||
A handler that checks Content-Length, or reads request.body(), runs after
|
||||
the server has already buffered the stream. This counts bytes at receive
|
||||
and returns 413 without handing the rest to the app. WebSocket frames are
|
||||
not HTTP bodies and are left alone.
|
||||
"""
|
||||
|
||||
def __init__(self, app: Any, max_bytes: int) -> None:
|
||||
self.app = app
|
||||
self.max_bytes = int(max_bytes)
|
||||
|
||||
async def __call__(self, scope, receive, send) -> None:
|
||||
if scope.get("type") != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
seen = 0
|
||||
blocked = False
|
||||
|
||||
async def limited() -> Dict[str, Any]:
|
||||
nonlocal seen, blocked
|
||||
if blocked:
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
message = await receive()
|
||||
if message.get("type") != "http.request":
|
||||
return message
|
||||
body = message.get("body") or b""
|
||||
seen += len(body)
|
||||
if seen > self.max_bytes:
|
||||
blocked = True
|
||||
await send(
|
||||
{
|
||||
"type": "http.response.start",
|
||||
"status": 413,
|
||||
"headers": [(b"content-type", b"application/json")],
|
||||
}
|
||||
)
|
||||
await send(
|
||||
{
|
||||
"type": "http.response.body",
|
||||
"body": b'{"error":{"code":"PAYLOAD_TOO_LARGE","message":"request body too large"}}',
|
||||
}
|
||||
)
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
return message
|
||||
|
||||
try:
|
||||
await self.app(scope, limited, send)
|
||||
except RuntimeError:
|
||||
if not blocked:
|
||||
raise
|
||||
|
||||
|
||||
SERVICE_TYPE = "_dorayaki._tcp"
|
||||
|
||||
|
||||
def _lan_address() -> Optional[str]:
|
||||
"""Address a phone on this network can open. Loopback is not one.
|
||||
|
||||
The demo launcher uses `ipconfig getifaddr en0` then `en1`. A UDP connect
|
||||
is the fallback when those interfaces are absent.
|
||||
"""
|
||||
if shutil.which("ipconfig"):
|
||||
for iface in ("en0", "en1"):
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
["ipconfig", "getifaddr", iface], stderr=subprocess.DEVNULL, text=True
|
||||
).strip()
|
||||
except (subprocess.CalledProcessError, OSError):
|
||||
continue
|
||||
if out and not is_loopback_host(out):
|
||||
return out
|
||||
try:
|
||||
probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
probe.connect(("192.0.2.1", 9))
|
||||
found = probe.getsockname()[0]
|
||||
finally:
|
||||
probe.close()
|
||||
except OSError:
|
||||
return None
|
||||
if not found or is_loopback_host(found):
|
||||
return None
|
||||
return found
|
||||
|
||||
|
||||
def advertise_host(bind_host: Optional[str]) -> Optional[str]:
|
||||
"""The address clients should browse. A loopback bind is not advertised.
|
||||
|
||||
A wildcard bind (0.0.0.0, ::) has no address of its own, so the record
|
||||
uses the LAN address the demo launcher would hand a phone.
|
||||
"""
|
||||
text = (bind_host or "").strip()
|
||||
if text.startswith("[") and text.endswith("]"):
|
||||
text = text[1:-1]
|
||||
if is_loopback_host(text):
|
||||
return None
|
||||
if text in ("", "0.0.0.0", "::"):
|
||||
return _lan_address()
|
||||
try:
|
||||
parsed = ipaddress.ip_address(text)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.is_loopback or parsed.is_unspecified:
|
||||
return None if parsed.is_loopback else _lan_address()
|
||||
return str(parsed)
|
||||
|
||||
|
||||
class ServiceAdvertiser:
|
||||
"""Register `_dorayaki._tcp` with the system mDNS daemon and drop it on close.
|
||||
|
||||
ponytail: macOS `dns-sd` only. A Linux host needs `avahi-publish` wired the
|
||||
same way; do not add a Python mDNS package for this one record.
|
||||
"""
|
||||
|
||||
def __init__(self, host: str, port: int, name: str = "Dorayaki") -> None:
|
||||
self.host = host
|
||||
self.port = int(port)
|
||||
self.name = name
|
||||
self._process: Optional[subprocess.Popen] = None
|
||||
|
||||
def start(self) -> bool:
|
||||
if self._process is not None:
|
||||
return True
|
||||
binary = shutil.which("dns-sd")
|
||||
if not binary:
|
||||
return False
|
||||
self._process = subprocess.Popen(
|
||||
[binary, "-P", self.name, SERVICE_TYPE, "local", str(self.port), self.host, self.host],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
return True
|
||||
|
||||
def close(self) -> None:
|
||||
process = self._process
|
||||
self._process = None
|
||||
if process is None or process.poll() is not None:
|
||||
return
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
return
|
||||
try:
|
||||
process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
return
|
||||
process.wait(timeout=2)
|
||||
|
|
@ -3,12 +3,24 @@
|
|||
*
|
||||
* A client of the recognition server, nothing else: live frames go out over
|
||||
* /v1/stream, stills go out over /v1/recognize. Point it at another server with
|
||||
* ?server=https://host:port and pass a token with ?token=...
|
||||
* ?server=https://host:port. A token, when the server requires one, comes from
|
||||
* sessionStorage (key dorayaki.token) or the fragment #token=..., never the query.
|
||||
*/
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const SERVER = (params.get('server') || '').replace(/\/$/, '');
|
||||
const TOKEN = params.get('token') || '';
|
||||
const TOKEN = readToken();
|
||||
|
||||
function readToken() {
|
||||
const stored = sessionStorage.getItem('dorayaki.token') || '';
|
||||
const fragment = new URLSearchParams((location.hash || '').replace(/^#/, ''));
|
||||
const fromHash = fragment.get('token') || '';
|
||||
if (fromHash) {
|
||||
sessionStorage.setItem('dorayaki.token', fromHash);
|
||||
history.replaceState(null, '', location.pathname + location.search);
|
||||
}
|
||||
return fromHash || stored;
|
||||
}
|
||||
|
||||
const I18N = {
|
||||
en: {
|
||||
|
|
@ -363,7 +375,7 @@ function thumbUrl(skuId) {
|
|||
|
||||
async function listCameras() {
|
||||
const select = el('sourceSelect');
|
||||
select.innerHTML = '';
|
||||
select.replaceChildren();
|
||||
if (!navigator.mediaDevices || !window.isSecureContext) {
|
||||
const option = document.createElement('option');
|
||||
option.value = 'file';
|
||||
|
|
@ -390,13 +402,16 @@ function connectStream() {
|
|||
if (state.ws && (state.ws.readyState === WebSocket.OPEN || state.ws.readyState === WebSocket.CONNECTING)) return;
|
||||
setConnection('connecting');
|
||||
const base = SERVER || `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}`;
|
||||
const url = `${base.replace(/^http/, 'ws')}/v1/stream${TOKEN ? `?token=${encodeURIComponent(TOKEN)}` : ''}`;
|
||||
const url = `${base.replace(/^http/, 'ws')}/v1/stream`;
|
||||
const socket = new WebSocket(url);
|
||||
socket.binaryType = 'arraybuffer';
|
||||
state.ws = socket;
|
||||
|
||||
socket.onopen = () => {
|
||||
state.reconnectDelay = 1000;
|
||||
// Browsers cannot set Authorization on a WebSocket. The server hello
|
||||
// arrives first; this frame is the token, and it is not a URL.
|
||||
if (TOKEN) socket.send(JSON.stringify({ type: 'auth', token: TOKEN }));
|
||||
setConnection('live');
|
||||
socket.send(JSON.stringify({ type: 'config', method: state.method, empty_slots: state.showEmpty }));
|
||||
startSending();
|
||||
|
|
@ -464,25 +479,25 @@ function sendFrame() {
|
|||
|
||||
function renderCompliance(compliance) {
|
||||
const strip = el('complianceStrip');
|
||||
strip.replaceChildren();
|
||||
if (!compliance) { strip.hidden = true; return; }
|
||||
const rows = compliance.rows || [];
|
||||
const items = rows.slice(0, 3).map(
|
||||
(row) => `<span class="compliance-item">⚠ ${t('rule_' + row.rule)} · ${t('row')} ${row.row_index} (${row.missing})</span>`
|
||||
);
|
||||
if (rows.length > 3) {
|
||||
items.push(`<span class="compliance-item">+${rows.length - 3} ${t('more')}</span>`);
|
||||
}
|
||||
const hints = compliance.hints || [];
|
||||
if (!items.length && hints.length) {
|
||||
strip.hidden = false;
|
||||
strip.dataset.kind = 'hint';
|
||||
strip.innerHTML = hints.map((hint) => `<span class="compliance-item">${hint.detail}</span>`).join('');
|
||||
return;
|
||||
const chips = [];
|
||||
for (const row of rows.slice(0, 3)) {
|
||||
chips.push(`⚠ ${t('rule_' + row.rule)} · ${t('row')} ${row.row_index} (${row.missing})`);
|
||||
}
|
||||
if (!items.length) { strip.hidden = true; return; }
|
||||
if (rows.length > 3) chips.push(`+${rows.length - 3} ${t('more')}`);
|
||||
const texts = chips.length ? chips : hints.map((hint) => hint.detail);
|
||||
if (!texts.length) { strip.hidden = true; return; }
|
||||
strip.hidden = false;
|
||||
strip.dataset.kind = 'violation';
|
||||
strip.innerHTML = items.join('');
|
||||
strip.dataset.kind = chips.length ? 'violation' : 'hint';
|
||||
for (const text of texts) {
|
||||
const item = document.createElement('span');
|
||||
item.className = 'compliance-item';
|
||||
item.textContent = text;
|
||||
strip.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
function handleResult(message) {
|
||||
|
|
@ -536,7 +551,7 @@ async function loadSamples() {
|
|||
const response = await fetch(api('/v1/samples'), { headers: headers() });
|
||||
const data = await response.json();
|
||||
const select = el('sampleSelect');
|
||||
select.innerHTML = '';
|
||||
select.replaceChildren();
|
||||
for (const sample of data.samples) {
|
||||
const option = document.createElement('option');
|
||||
option.value = sample.id;
|
||||
|
|
@ -634,24 +649,42 @@ function renderInView() {
|
|||
}
|
||||
}
|
||||
}
|
||||
list.replaceChildren();
|
||||
if (!counts.size) {
|
||||
list.innerHTML = `<div class="placeholder small">${t('inviewPlaceholder')}</div>`;
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'placeholder small';
|
||||
empty.textContent = t('inviewPlaceholder');
|
||||
list.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
const rows = [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([skuId, facings]) => {
|
||||
const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
||||
for (const [skuId, facings] of ranked) {
|
||||
const product = productFor(skuId);
|
||||
const name = product ? product.product_name : skuId;
|
||||
const price = product ? `¥${Math.round(product.price)}` : '';
|
||||
return `<div class="inview-row" data-sku="${skuId}">
|
||||
<img class="inview-thumb" src="${thumbUrl(skuId)}" alt="">
|
||||
<div class="inview-text"><div class="inview-name">${name}</div>
|
||||
<div class="inview-meta">${price} · ${t('facings')} ${facings}</div></div>
|
||||
<div class="inview-facings">${facings}</div></div>`;
|
||||
}).join('');
|
||||
list.innerHTML = rows;
|
||||
list.querySelectorAll('.inview-row').forEach((row) => {
|
||||
row.addEventListener('click', () => selectBySku(row.dataset.sku));
|
||||
});
|
||||
const row = document.createElement('div');
|
||||
row.className = 'inview-row';
|
||||
row.dataset.sku = skuId;
|
||||
const thumb = document.createElement('img');
|
||||
thumb.className = 'inview-thumb';
|
||||
thumb.alt = '';
|
||||
thumb.src = thumbUrl(skuId);
|
||||
const text = document.createElement('div');
|
||||
text.className = 'inview-text';
|
||||
const nameEl = document.createElement('div');
|
||||
nameEl.className = 'inview-name';
|
||||
nameEl.textContent = name;
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'inview-meta';
|
||||
meta.textContent = `${price} · ${t('facings')} ${facings}`;
|
||||
text.append(nameEl, meta);
|
||||
const count = document.createElement('div');
|
||||
count.className = 'inview-facings';
|
||||
count.textContent = String(facings);
|
||||
row.append(thumb, text, count);
|
||||
row.addEventListener('click', () => selectBySku(skuId));
|
||||
list.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function selectBySku(skuId) {
|
||||
|
|
@ -715,9 +748,19 @@ function renderOcrSection(tag, reconciliation) {
|
|||
if (tag.state === 'absent') lines.push([t('ocrState'), t('ocrAbsent'), false]);
|
||||
}
|
||||
if (reconciliation && reconciliation.message) lines.push([t('ocrNote'), reconciliation.message, state === 'mismatch']);
|
||||
rows.innerHTML = lines
|
||||
.map(([key, value, bad]) => `<div class="ocr-row"><span class="ocr-key">${key}</span><span class="ocr-val${bad ? ' bad' : ''}">${value}</span></div>`)
|
||||
.join('');
|
||||
rows.replaceChildren();
|
||||
for (const [key, value, bad] of lines) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'ocr-row';
|
||||
const keyEl = document.createElement('span');
|
||||
keyEl.className = 'ocr-key';
|
||||
keyEl.textContent = key;
|
||||
const valueEl = document.createElement('span');
|
||||
valueEl.className = bad ? 'ocr-val bad' : 'ocr-val';
|
||||
valueEl.textContent = value;
|
||||
row.append(keyEl, valueEl);
|
||||
rows.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function showProductCard(product, identity) {
|
||||
|
|
@ -755,12 +798,22 @@ function showCandidatesCard(identity) {
|
|||
el('metricReceipt').textContent = '—';
|
||||
el('metricExpiry').textContent = '—';
|
||||
const candidates = el('candidates');
|
||||
const rows = (identity.candidates || []).map((candidate) => {
|
||||
candidates.replaceChildren();
|
||||
const title = document.createElement('div');
|
||||
title.className = 'candidates-title';
|
||||
title.textContent = t('candidatesTitle');
|
||||
candidates.appendChild(title);
|
||||
for (const candidate of identity.candidates || []) {
|
||||
const product = productFor(candidate.sku_id);
|
||||
const name = product ? product.product_name : candidate.sku_id;
|
||||
return `<div class="candidate-row"><span>${name}</span><span>${candidate.score}</span></div>`;
|
||||
}).join('');
|
||||
candidates.innerHTML = `<div class="candidates-title">${t('candidatesTitle')}</div>${rows}`;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'candidate-row';
|
||||
const name = document.createElement('span');
|
||||
name.textContent = product ? product.product_name : candidate.sku_id;
|
||||
const score = document.createElement('span');
|
||||
score.textContent = String(candidate.score);
|
||||
row.append(name, score);
|
||||
candidates.appendChild(row);
|
||||
}
|
||||
candidates.hidden = false;
|
||||
el('showMatchBtn').disabled = false;
|
||||
renderOcrSection(state.currentTag, state.currentReconciliation);
|
||||
|
|
@ -889,10 +942,18 @@ function handleInspect(result) {
|
|||
if (result.crop_jpeg_b64) {
|
||||
el('productThumb').src = result.crop_jpeg_b64;
|
||||
}
|
||||
const box = el('matchBox');
|
||||
box.replaceChildren();
|
||||
if (result.match_visualization_jpeg_b64) {
|
||||
el('matchBox').innerHTML = `<img src="${result.match_visualization_jpeg_b64}" alt="feature matches">`;
|
||||
const image = document.createElement('img');
|
||||
image.alt = 'feature matches';
|
||||
image.src = result.match_visualization_jpeg_b64;
|
||||
box.appendChild(image);
|
||||
} else {
|
||||
el('matchBox').innerHTML = `<span class="match-placeholder">${t('matchPlaceholder')}</span>`;
|
||||
const placeholder = document.createElement('span');
|
||||
placeholder.className = 'match-placeholder';
|
||||
placeholder.textContent = t('matchPlaceholder');
|
||||
box.appendChild(placeholder);
|
||||
}
|
||||
const identity = result.identity || {};
|
||||
const parts = [];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue