perf: reuse sample dimensions and catalog digest

This commit is contained in:
kiyo9w 2026-09-28 14:06:34 +09:00
parent 7142f10cfe
commit 1064e356de
2 changed files with 78 additions and 17 deletions

View file

@ -21,6 +21,7 @@ import argparse
import asyncio import asyncio
import base64 import base64
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from dataclasses import dataclass
import json import json
import logging import logging
import os import os
@ -29,8 +30,7 @@ import sys
import threading import threading
import time import time
import uuid import uuid
from dataclasses import dataclass from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, List, Optional
# Keep the demo off the network before anything imports ultralytics. # Keep the demo off the network before anything imports ultralytics.
os.environ.setdefault("YOLO_OFFLINE", "1") os.environ.setdefault("YOLO_OFFLINE", "1")
@ -296,6 +296,9 @@ def create_app(
recorder = FrameRecorder(settings.record_frames, every=settings.record_every) \ recorder = FrameRecorder(settings.record_frames, every=settings.record_every) \
if settings.record_frames else None if settings.record_frames else None
samples = {item["id"]: item for item in get_available_shelf_images("add")} 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: Dict[str, LiveSession] = {}
sessions_lock = asyncio.Lock() sessions_lock = asyncio.Lock()
app.state.engine = engine app.state.engine = engine
@ -371,15 +374,37 @@ def create_app(
raise _error("NOT_FOUND", f"unknown sku '{sku_id}'", 404) raise _error("NOT_FOUND", f"unknown sku '{sku_id}'", 404)
return {"status": "success", "product": product} return {"status": "success", "product": product}
def _sample_listing() -> List[Dict[str, Any]]: def _sample_dimensions(path: str) -> Optional[Tuple[int, int]]:
listing = [] 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 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(): for item in samples.values():
entry = {"id": item["id"], "label": item["label"], "url": f"/v1/samples/{item['id']}"} entry = {"id": item["id"], "label": item["label"], "url": f"/v1/samples/{item['id']}"}
image = cv2.imread(item["path"]) dimensions = _sample_dimensions(item["path"])
if image is not None: if dimensions is not None:
entry["width"], entry["height"] = int(image.shape[1]), int(image.shape[0]) entry["width"], entry["height"] = dimensions
listing.append(entry) listing.append(entry)
return listing return listing
@ -693,7 +718,6 @@ def create_app(
async def send(payload: Dict[str, Any]) -> None: async def send(payload: Dict[str, Any]) -> None:
async with send_lock: async with send_lock:
await websocket.send_text(json.dumps(payload)) await websocket.send_text(json.dumps(payload))
await send( await send(
{ {
"type": "hello", "type": "hello",
@ -717,7 +741,7 @@ def create_app(
async def processing_loop() -> None: async def processing_loop() -> None:
while not session.closed: 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: if pending is None:
continue continue
frame_id, payload = pending frame_id, payload = pending

View file

@ -273,6 +273,10 @@ class RecognitionEngine:
self.settings.image_store_max_bytes, self.settings.image_store_max_bytes,
) )
self._catalog_cache: Optional[Tuple[str, List[Dict[str, Any]]]] = None 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() self._started_at = time.time()
# ------------------------------------------------------------------ lifecycle # ------------------------------------------------------------------ lifecycle
@ -477,6 +481,8 @@ class RecognitionEngine:
len(gallery.sku_ids), len(gallery.sku_ids),
gallery.device, gallery.device,
) )
if gallery.head_reason != "ok":
LOGGER.warning("identity head: %s (%s)", gallery.head_reason, gallery._head_path)
else: else:
LOGGER.warning("identity gallery unavailable; gallery requests stay unrecognized") LOGGER.warning("identity gallery unavailable; gallery requests stay unrecognized")
return gallery if loaded else None return gallery if loaded else None
@ -606,24 +612,53 @@ class RecognitionEngine:
# ------------------------------------------------------------------ catalog # ------------------------------------------------------------------ catalog
def catalog_version(self) -> str: 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) csv_path = os.path.join(self.settings.stock_dir, self.settings.stock_csv)
try: try:
with open(csv_path, "rb") as handle: stat = os.stat(csv_path)
digest.update(handle.read()) csv_key = (stat.st_mtime_ns, stat.st_size)
except OSError: except OSError:
digest.update(b"missing-csv") csv_key = None
try: try:
entries = sorted( entries = tuple(sorted(
(name, os.path.getsize(os.path.join(self.settings.stock_dir, name))) (name, os.path.getsize(os.path.join(self.settings.stock_dir, name)))
for name in os.listdir(self.settings.stock_dir) for name in os.listdir(self.settings.stock_dir)
if name.lower().endswith((".jpeg", ".jpg", ".png")) if name.lower().endswith((".jpeg", ".jpg", ".png"))
) ))
except OSError: 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: for name, size in entries:
digest.update(f"{name}:{size}".encode("utf-8")) 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: def model_version(self) -> str:
try: try:
@ -689,6 +724,8 @@ class RecognitionEngine:
product = self.inventory.get_product(sku_id) or {} product = self.inventory.get_product(sku_id) or {}
payload = self._product_payload(sku_id, product) payload = self._product_payload(sku_id, product)
self._catalog_cache = None self._catalog_cache = None
with self._catalog_version_lock:
self._catalog_version_cache = None
return payload return payload
def sku_count(self) -> int: def sku_count(self) -> int: