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:
parent
b443e3c381
commit
0f1d46f9b3
4 changed files with 73 additions and 32 deletions
|
|
@ -99,8 +99,17 @@ from ultralytics import YOLO
|
|||
class ShelfProductDetector:
|
||||
"""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.device = device
|
||||
self.imgsz = imgsz
|
||||
self.half = half
|
||||
if not os.path.exists(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": []}
|
||||
|
||||
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 = []
|
||||
if results and len(results) > 0:
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ the methodology of Electronics 2023, 12(17), 3640.
|
|||
import os
|
||||
import cv2
|
||||
import numpy as np
|
||||
import threading
|
||||
from typing import Dict, List, Tuple, Optional, Any
|
||||
from identity import decide_identity
|
||||
|
||||
|
||||
class LocalFeatureExtractor:
|
||||
|
|
@ -122,6 +124,18 @@ class ProductFeatureMatcher:
|
|||
def __init__(self, stock_dir: str = "stock"):
|
||||
self.db = TemplateFeatureDatabase(stock_dir)
|
||||
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(
|
||||
self,
|
||||
|
|
@ -173,26 +187,29 @@ class ProductFeatureMatcher:
|
|||
except Exception:
|
||||
return len(good_matches), good_matches, None
|
||||
|
||||
def classify_crop(
|
||||
def score_crop(
|
||||
self,
|
||||
crop_bgr: np.ndarray,
|
||||
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'.
|
||||
"""
|
||||
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)
|
||||
method = method.lower()
|
||||
extractors = self.thread_extractors()
|
||||
|
||||
scores: Dict[str, float] = {}
|
||||
details: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
if method in ["surf", "orb", "brisk"]:
|
||||
extractor = self.extractors.get(method)
|
||||
extractor = extractors.get(method)
|
||||
if extractor is None:
|
||||
extractor = LocalFeatureExtractor(method)
|
||||
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
|
||||
query_features = {}
|
||||
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)
|
||||
|
||||
for sku_id, tmpl in self.db.templates.items():
|
||||
|
|
@ -242,7 +259,7 @@ class ProductFeatureMatcher:
|
|||
kp_q, des_q = query_features[sm]
|
||||
kp_t = tmpl["features"][sm]["keypoints"]
|
||||
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)
|
||||
inliers, inlier_list, _ = self.compute_geometric_inliers(kp_q, kp_t, matches)
|
||||
|
|
@ -267,29 +284,29 @@ class ProductFeatureMatcher:
|
|||
"kp_tmpl": tmpl["features"]["surf"]["keypoints"]
|
||||
}
|
||||
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}"}
|
||||
|
||||
# Rank templates by score
|
||||
if not scores:
|
||||
return {"status": "error", "message": "No templates available"}
|
||||
|
||||
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]
|
||||
}
|
||||
scores, details = self.score_crop(crop_bgr, method=method)
|
||||
ranked = decide_identity(scores, details=details, method=method)
|
||||
return ranked
|
||||
|
||||
def generate_match_visualization(
|
||||
self,
|
||||
|
|
@ -306,7 +323,7 @@ class ProductFeatureMatcher:
|
|||
crop_gray = cv2.cvtColor(crop_bgr, cv2.COLOR_BGR2GRAY)
|
||||
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_t = tmpl["features"][eval_method]["keypoints"]
|
||||
des_t = tmpl["features"][eval_method]["descriptors"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue