perf: reuse sample dimensions and catalog digest
This commit is contained in:
parent
7142f10cfe
commit
1064e356de
2 changed files with 78 additions and 17 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue