refactor: make the matcher and detector safe for concurrent clients

The recognition server will classify crops from several clients at once. Two
things blocked that:

- OpenCV Feature2D objects were shared across calls, so parallel
  classify_crop() calls could corrupt each other. Each thread now gets its own
  extractor set; the template cache is untouched.
- score_crop() is extracted from classify_crop() so callers that need every SKU
  score (live track aggregation) do not have to re-derive it from the top-5
  list. classify_crop() output is byte-identical to before (verified against the
  pre-change module on 82 real crops x 4 methods).
- ShelfProductDetector accepts device/imgsz so the server can pin inference.

Dependencies: pin ultralytics to the version that wrote the checkpoint and that
the session tracker targets (8.4.153), add lap so ultralytics never
pip-installs it mid-request, and add websockets for the streaming endpoint.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
kiyo9w 2026-09-24 11:43:24 +09:00
parent b443e3c381
commit 0f1d46f9b3
4 changed files with 73 additions and 32 deletions

View file

@ -99,8 +99,17 @@ from ultralytics import YOLO
class ShelfProductDetector: class ShelfProductDetector:
"""Wraps the YOLO detector for supermarket shelf products.""" """Wraps the YOLO detector for supermarket shelf products."""
def __init__(self, weights_path: str = "runs/detect/train/weights/best.pt"): def __init__(
self,
weights_path: str = "runs/detect/train/weights/best.pt",
device: Optional[str] = None,
imgsz: int = 640,
half: bool = False
):
self.weights_path = weights_path self.weights_path = weights_path
self.device = device
self.imgsz = imgsz
self.half = half
if not os.path.exists(weights_path): if not os.path.exists(weights_path):
raise FileNotFoundError(f"YOLO weights not found at: {weights_path}") raise FileNotFoundError(f"YOLO weights not found at: {weights_path}")
@ -129,7 +138,14 @@ class ShelfProductDetector:
return {"status": "error", "message": "Failed to read image", "boxes": []} return {"status": "error", "message": "Failed to read image", "boxes": []}
h, w = image_bgr.shape[:2] h, w = image_bgr.shape[:2]
results = self.model(image_bgr, conf=conf_threshold, iou=iou_threshold, verbose=False) predict_kwargs: Dict[str, Any] = dict(
conf=conf_threshold, iou=iou_threshold, imgsz=self.imgsz, verbose=False
)
if self.device:
predict_kwargs["device"] = self.device
if self.half:
predict_kwargs["half"] = True
results = self.model(image_bgr, **predict_kwargs)
boxes_list = [] boxes_list = []
if results and len(results) > 0: if results and len(results) > 0:

View file

@ -7,7 +7,9 @@ the methodology of Electronics 2023, 12(17), 3640.
import os import os
import cv2 import cv2
import numpy as np import numpy as np
import threading
from typing import Dict, List, Tuple, Optional, Any from typing import Dict, List, Tuple, Optional, Any
from identity import decide_identity
class LocalFeatureExtractor: class LocalFeatureExtractor:
@ -122,6 +124,18 @@ class ProductFeatureMatcher:
def __init__(self, stock_dir: str = "stock"): def __init__(self, stock_dir: str = "stock"):
self.db = TemplateFeatureDatabase(stock_dir) self.db = TemplateFeatureDatabase(stock_dir)
self.extractors = self.db.extractors self.extractors = self.db.extractors
self._thread_local = threading.local()
def thread_extractors(self) -> Dict[str, "LocalFeatureExtractor"]:
"""One extractor set per thread: OpenCV Feature2D objects are not safe to
call concurrently from several threads."""
extractors = getattr(self._thread_local, "extractors", None)
if extractors is None:
extractors = {
name: LocalFeatureExtractor(name) for name in ("surf", "orb", "brisk")
}
self._thread_local.extractors = extractors
return extractors
def match_descriptors( def match_descriptors(
self, self,
@ -173,26 +187,29 @@ class ProductFeatureMatcher:
except Exception: except Exception:
return len(good_matches), good_matches, None return len(good_matches), good_matches, None
def classify_crop( def score_crop(
self, self,
crop_bgr: np.ndarray, crop_bgr: np.ndarray,
method: str = "hybrid" method: str = "hybrid"
) -> Dict[str, Any]: ) -> Tuple[Dict[str, float], Dict[str, Dict[str, Any]]]:
""" """
Classifies a product crop against all SKU templates. Scores a product crop against every SKU template.
Returns (scores, details); raises ValueError for an unsupported method and
returns empty results for an empty crop.
Methods supported: 'surf', 'orb', 'brisk', 'hybrid'. Methods supported: 'surf', 'orb', 'brisk', 'hybrid'.
""" """
if crop_bgr is None or crop_bgr.size == 0: if crop_bgr is None or crop_bgr.size == 0:
return {"status": "error", "message": "Empty crop image"} return {}, {}
crop_gray = cv2.cvtColor(crop_bgr, cv2.COLOR_BGR2GRAY) crop_gray = cv2.cvtColor(crop_bgr, cv2.COLOR_BGR2GRAY)
method = method.lower() method = method.lower()
extractors = self.thread_extractors()
scores: Dict[str, float] = {} scores: Dict[str, float] = {}
details: Dict[str, Dict[str, Any]] = {} details: Dict[str, Dict[str, Any]] = {}
if method in ["surf", "orb", "brisk"]: if method in ["surf", "orb", "brisk"]:
extractor = self.extractors.get(method) extractor = extractors.get(method)
if extractor is None: if extractor is None:
extractor = LocalFeatureExtractor(method) extractor = LocalFeatureExtractor(method)
kp_query, des_query = extractor.detect_and_compute(crop_gray) kp_query, des_query = extractor.detect_and_compute(crop_gray)
@ -231,7 +248,7 @@ class ProductFeatureMatcher:
# Pre-compute features for query crop across all 3 extractors # Pre-compute features for query crop across all 3 extractors
query_features = {} query_features = {}
for sm in sub_methods: for sm in sub_methods:
kp_q, des_q = self.extractors[sm].detect_and_compute(crop_gray) kp_q, des_q = extractors[sm].detect_and_compute(crop_gray)
query_features[sm] = (kp_q, des_q) query_features[sm] = (kp_q, des_q)
for sku_id, tmpl in self.db.templates.items(): for sku_id, tmpl in self.db.templates.items():
@ -242,7 +259,7 @@ class ProductFeatureMatcher:
kp_q, des_q = query_features[sm] kp_q, des_q = query_features[sm]
kp_t = tmpl["features"][sm]["keypoints"] kp_t = tmpl["features"][sm]["keypoints"]
des_t = tmpl["features"][sm]["descriptors"] des_t = tmpl["features"][sm]["descriptors"]
norm_t = self.extractors[sm].norm_type norm_t = extractors[sm].norm_type
matches = self.match_descriptors(des_q, des_t, norm_t, ratio_thresh=0.75) matches = self.match_descriptors(des_q, des_t, norm_t, ratio_thresh=0.75)
inliers, inlier_list, _ = self.compute_geometric_inliers(kp_q, kp_t, matches) inliers, inlier_list, _ = self.compute_geometric_inliers(kp_q, kp_t, matches)
@ -267,29 +284,29 @@ class ProductFeatureMatcher:
"kp_tmpl": tmpl["features"]["surf"]["keypoints"] "kp_tmpl": tmpl["features"]["surf"]["keypoints"]
} }
else: else:
raise ValueError(f"Unknown method: {method}")
return scores, details
def classify_crop(
self,
crop_bgr: np.ndarray,
method: str = "hybrid"
) -> Dict[str, Any]:
"""
Classifies a product crop against all SKU templates.
Methods supported: 'surf', 'orb', 'brisk', 'hybrid'.
"""
if crop_bgr is None or crop_bgr.size == 0:
return {"status": "error", "message": "Empty crop image"}
method = method.lower()
if method not in ["surf", "orb", "brisk", "hybrid"]:
return {"status": "error", "message": f"Unknown method: {method}"} return {"status": "error", "message": f"Unknown method: {method}"}
# Rank templates by score scores, details = self.score_crop(crop_bgr, method=method)
if not scores: ranked = decide_identity(scores, details=details, method=method)
return {"status": "error", "message": "No templates available"} return ranked
sorted_candidates = sorted(scores.items(), key=lambda x: x[1], reverse=True)
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)
confidence = round(min(1.0, top_score / max(1.0, (top_score + runner_up_score * 0.5))), 4)
return {
"status": "success",
"method": method,
"best_sku": top_sku,
"score": round(top_score, 2),
"confidence": confidence,
"ranked_candidates": [
{"sku_id": s, "score": round(sc, 2)} for s, sc in sorted_candidates[:5]
],
"details": details[top_sku]
}
def generate_match_visualization( def generate_match_visualization(
self, self,
@ -306,7 +323,7 @@ class ProductFeatureMatcher:
crop_gray = cv2.cvtColor(crop_bgr, cv2.COLOR_BGR2GRAY) crop_gray = cv2.cvtColor(crop_bgr, cv2.COLOR_BGR2GRAY)
eval_method = method if method in ["surf", "orb", "brisk"] else "surf" eval_method = method if method in ["surf", "orb", "brisk"] else "surf"
extractor = self.extractors[eval_method] extractor = self.thread_extractors()[eval_method]
kp_q, des_q = extractor.detect_and_compute(crop_gray) kp_q, des_q = extractor.detect_and_compute(crop_gray)
kp_t = tmpl["features"][eval_method]["keypoints"] kp_t = tmpl["features"][eval_method]["keypoints"]
des_t = tmpl["features"][eval_method]["descriptors"] des_t = tmpl["features"][eval_method]["descriptors"]

View file

@ -0,0 +1,3 @@
-r requirements.txt
# FastAPI TestClient
httpx>=0.27

View file

@ -1,6 +1,11 @@
torch>=2.0.0 torch>=2.0.0
torchvision>=0.15.0 torchvision>=0.15.0
ultralytics>=8.0.0 # Exact: tracking.py relies on BYTETracker internals; the trained checkpoint was written by 8.4.152
ultralytics==8.4.153
# ByteTrack linear assignment; pinned so ultralytics never pip-installs it at runtime
lap>=0.5.12
# uvicorn WebSocket support for /v1/stream
websockets>=13
opencv-python>=4.8.0 opencv-python>=4.8.0
numpy>=1.23.0,<2.0.0 numpy>=1.23.0,<2.0.0
fastapi>=0.100.0 fastapi>=0.100.0