"""Optional YOLO + matcher adapter for PerceptionService. Imported only when OpenCV/YOLO are available. Tests use PerceptionAdapter fakes instead. This does not keep process-global active images. """ from __future__ import annotations import threading from typing import Any, Dict, List import cv2 import numpy as np from observe import PerceptionAdapter class YoloMatcherAdapter(PerceptionAdapter): def __init__(self, detector: Any, matcher: Any, method: str = "hybrid"): super().__init__( model_version=getattr(detector, "weights_path", "yolo-local"), matcher_method=method, feature_extractor="surf-or-sift+orb+brisk", ) self.detector = detector self.matcher = matcher # observe() calls detect() then classify_box() on the same thread; keep # the decoded frame per thread so concurrent sessions cannot swap images. self._thread_local = threading.local() def detect(self, frame_bytes: bytes) -> Dict[str, Any]: array = np.frombuffer(frame_bytes, dtype=np.uint8) image = cv2.imdecode(array, cv2.IMREAD_COLOR) if image is None: return { "width": 0, "height": 0, "boxes": [], "empty_slots": [], "quality": {"limitation": "undecodable-frame"}, } self._thread_local.image = image h, w = image.shape[:2] det = self.detector.detect(image) boxes: List[Dict[str, Any]] = det.get("boxes", []) empty = self.detector.detect_empty_slots(image.shape, boxes) return { "width": w, "height": h, "boxes": boxes, "empty_slots": empty, "quality": {"decoded": True}, } def classify_box(self, box: Dict[str, Any]) -> Dict[str, float]: image = getattr(self._thread_local, "image", None) if image is None: return {} crop = self.detector.crop_roi(image, box["box"]) ranked = self.matcher.classify_crop(crop, method=self.matcher_method) return { row["sku_id"]: float(row["score"]) for row in ranked.get("ranked_candidates", []) }