diff --git a/recognization/code/app.py b/recognization/code/app.py index bcbd1ff..abb1e8c 100644 --- a/recognization/code/app.py +++ b/recognization/code/app.py @@ -21,6 +21,7 @@ import argparse import asyncio import base64 from contextlib import asynccontextmanager +from dataclasses import dataclass import json import logging import os @@ -29,8 +30,7 @@ import sys import threading import time import uuid -from dataclasses import dataclass -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple # Keep the demo off the network before anything imports ultralytics. os.environ.setdefault("YOLO_OFFLINE", "1") @@ -296,6 +296,9 @@ def create_app( recorder = FrameRecorder(settings.record_frames, every=settings.record_every) \ if settings.record_frames else None samples = {item["id"]: item for item in get_available_shelf_images("add")} + # path -> (mtime_ns, size, width, height). Dropped when the file changes. + sample_dimensions: Dict[str, Tuple[int, int, int, int]] = {} + sample_dimensions_lock = threading.Lock() sessions: Dict[str, LiveSession] = {} sessions_lock = asyncio.Lock() app.state.engine = engine @@ -371,15 +374,37 @@ def create_app( raise _error("NOT_FOUND", f"unknown sku '{sku_id}'", 404) return {"status": "success", "product": product} - def _sample_listing() -> List[Dict[str, Any]]: - listing = [] + def _sample_dimensions(path: str) -> Optional[Tuple[int, int]]: + try: + stat = os.stat(path) + except OSError: + with sample_dimensions_lock: + sample_dimensions.pop(path, None) + return None + key = (stat.st_mtime_ns, stat.st_size) + with sample_dimensions_lock: + cached = sample_dimensions.get(path) + if cached is not None and cached[:2] == key: + return cached[2], cached[3] import cv2 + image = cv2.imread(path) + if image is None: + with sample_dimensions_lock: + sample_dimensions.pop(path, None) + return None + width, height = int(image.shape[1]), int(image.shape[0]) + with sample_dimensions_lock: + sample_dimensions[path] = (key[0], key[1], width, height) + return width, height + + def _sample_listing() -> List[Dict[str, Any]]: + listing = [] for item in samples.values(): entry = {"id": item["id"], "label": item["label"], "url": f"/v1/samples/{item['id']}"} - image = cv2.imread(item["path"]) - if image is not None: - entry["width"], entry["height"] = int(image.shape[1]), int(image.shape[0]) + dimensions = _sample_dimensions(item["path"]) + if dimensions is not None: + entry["width"], entry["height"] = dimensions listing.append(entry) return listing @@ -693,7 +718,6 @@ def create_app( async def send(payload: Dict[str, Any]) -> None: async with send_lock: await websocket.send_text(json.dumps(payload)) - await send( { "type": "hello", @@ -717,7 +741,7 @@ def create_app( async def processing_loop() -> None: while not session.closed: - pending = await loop.run_in_executor(None, session.take_pending, 0.25) + pending = await session.take_pending_async() if pending is None: continue frame_id, payload = pending diff --git a/recognization/code/engine.py b/recognization/code/engine.py index f63e714..6a84c92 100644 --- a/recognization/code/engine.py +++ b/recognization/code/engine.py @@ -273,6 +273,10 @@ class RecognitionEngine: self.settings.image_store_max_bytes, ) self._catalog_cache: Optional[Tuple[str, List[Dict[str, Any]]]] = None + # (version, csv mtime_ns, csv size, sorted image name:size). Version is + # the content digest; the rest is the cheap fingerprint that invalidates it. + self._catalog_version_cache: Optional[Tuple[str, int, int, Tuple[Tuple[str, int], ...]]] = None + self._catalog_version_lock = threading.Lock() self._started_at = time.time() # ------------------------------------------------------------------ lifecycle @@ -477,6 +481,8 @@ class RecognitionEngine: len(gallery.sku_ids), gallery.device, ) + if gallery.head_reason != "ok": + LOGGER.warning("identity head: %s (%s)", gallery.head_reason, gallery._head_path) else: LOGGER.warning("identity gallery unavailable; gallery requests stay unrecognized") return gallery if loaded else None @@ -606,24 +612,53 @@ class RecognitionEngine: # ------------------------------------------------------------------ catalog def catalog_version(self) -> str: - digest = hashlib.sha256() + """12-hex digest of stock.csv bytes plus template image names and sizes. + + Unchanged files reuse the digest. A stock update that has not been written + back to the CSV still changes the digest, because the in-memory fingerprint + is dropped. Same-size content rewritten without a new mtime is not detected + until the next stock update or a real stat change. + """ csv_path = os.path.join(self.settings.stock_dir, self.settings.stock_csv) try: - with open(csv_path, "rb") as handle: - digest.update(handle.read()) + stat = os.stat(csv_path) + csv_key = (stat.st_mtime_ns, stat.st_size) except OSError: - digest.update(b"missing-csv") + csv_key = None try: - entries = sorted( + entries = tuple(sorted( (name, os.path.getsize(os.path.join(self.settings.stock_dir, name))) for name in os.listdir(self.settings.stock_dir) if name.lower().endswith((".jpeg", ".jpg", ".png")) - ) + )) except OSError: - entries = [] + entries = () + fingerprint = ( + csv_key[0] if csv_key else -1, + csv_key[1] if csv_key else -1, + entries, + ) + with self._catalog_version_lock: + cached = self._catalog_version_cache + if cached is not None and csv_key is not None and cached[1:] == fingerprint: + return cached[0] + digest = hashlib.sha256() + if csv_key is None: + digest.update(b"missing-csv") + else: + try: + with open(csv_path, "rb") as handle: + digest.update(handle.read()) + except OSError: + digest.update(b"missing-csv") + csv_key = None for name, size in entries: digest.update(f"{name}:{size}".encode("utf-8")) - return digest.hexdigest()[:12] + version = digest.hexdigest()[:12] + if csv_key is not None: + with self._catalog_version_lock: + self._catalog_version_cache = (version, csv_key[0], csv_key[1], entries) + return version def model_version(self) -> str: try: @@ -689,6 +724,8 @@ class RecognitionEngine: product = self.inventory.get_product(sku_id) or {} payload = self._product_payload(sku_id, product) self._catalog_cache = None + with self._catalog_version_lock: + self._catalog_version_cache = None return payload def sku_count(self) -> int: