# Python PatMax 대체 구현 # ORB 특징점 매칭 (위치·회전 불변) + 엣지 NCC fallback (특징점 부족 시) import os import pickle import cv2 import numpy as np from typing import Optional _PATTERNS_PATH = os.path.join("assets", "patterns.pkl") SCORE_THRESHOLD = 60.0 _MAX_IMG_SIZE = 1200 # ORB 처리 전 긴 변 최대 픽셀 (속도·메모리 제한) _GOOD_MATCH_REF = 20 # good match 이 수 이상 → 100점 기준 class PatternMatcher: def __init__(self, threshold: float = SCORE_THRESHOLD): # {product_id: {"method": "orb"|"ncc", "des": ndarray|None, ...}} self._patterns: dict = {} self._threshold = threshold # ── 학습 ──────────────────────────────────────────────────────────── # def train(self, image: np.ndarray, product_id: int, product_info: dict, roi=None): """ roi: (x, y, w, h) 픽셀 좌표, None이면 전체 이미지. ORB로 특징점을 자동 검출해 등록. 특징점 10개 미만이면 엣지 NCC 방식으로 전환. """ gray = _to_gray(image) if roi is not None: x, y, rw, rh = roi x, y = max(0, x), max(0, y) rw = min(rw, gray.shape[1] - x) rh = min(rh, gray.shape[0] - y) gray = gray[y:y + rh, x:x + rw].copy() gray = _resize_if_large(gray) kp, des = cv2.ORB_create(nfeatures=1000).detectAndCompute(gray, None) if des is not None and len(kp) >= 10: method = "orb" n_kp = len(kp) edges = None else: method = "ncc" n_kp = 0 des = None edges = _to_edges(gray) name = (f"{product_info.get('name')} " f"{product_info.get('model')} {product_info.get('type')}") suffix = f" ORB 특징점 {n_kp}개" if method == "orb" else " 엣지 NCC (특징점 부족)" print(f"[PatternMatcher] 등록: id={product_id} {name}{suffix}") self._patterns[product_id] = { "method": method, "gray": gray, # 축소된 그레이 (시각화·fallback용) "des": des, # ORB 디스크립터 (np.ndarray) or None "n_kp": n_kp, "edges": edges, # Canny 엣지 or None "info": product_info, "roi": roi, } # ── 매칭 ──────────────────────────────────────────────────────────── # def match_all(self, image: np.ndarray) -> dict: """모든 등록 패턴에 대한 점수 반환 {product_id: score(0~100)}""" gray = _resize_if_large(_to_gray(image)) ncc_edges = None # NCC용 엣지는 필요할 때만 계산 scores = {} for pid, data in self._patterns.items(): if data.get("method") == "orb" and data.get("des") is not None: scores[pid] = _orb_score(gray, data["des"]) else: if ncc_edges is None: ncc_edges = _to_edges(gray) tmpl = data.get("edges") or _to_edges(data["gray"]) scores[pid] = _best_rotated_score(ncc_edges, tmpl) return scores # ── 저장 / 로드 ────────────────────────────────────────────────────── # def save(self, path: str = _PATTERNS_PATH): os.makedirs(os.path.dirname(path) or ".", exist_ok=True) with open(path, "wb") as f: pickle.dump({"patterns": self._patterns, "threshold": self._threshold}, f) print(f"[PatternMatcher] 저장: {len(self._patterns)}개 → {path}") def load(self, path: str = _PATTERNS_PATH) -> bool: if not os.path.exists(path): return False try: with open(path, "rb") as f: data = pickle.load(f) self._patterns = data.get("patterns", {}) self._threshold = data.get("threshold", SCORE_THRESHOLD) # 구버전 호환: method 키 없으면 NCC로 처리 for pat in self._patterns.values(): if "method" not in pat: pat["method"] = "ncc" if not pat.get("edges"): pat["edges"] = _to_edges(pat["gray"]) print(f"[PatternMatcher] 로드: {len(self._patterns)}개 ← {path}") return True except Exception as e: print(f"[PatternMatcher] 로드 실패: {e}") return False # ── 조회 ──────────────────────────────────────────────────────────── # def has_pattern(self, product_id: int) -> bool: return product_id in self._patterns def remove_pattern(self, product_id: int): self._patterns.pop(product_id, None) def get_product_info(self, product_id: int) -> Optional[dict]: data = self._patterns.get(product_id) return data["info"] if data else None def get_pattern_summary(self, product_id: int) -> str: """패턴 등록 방식 및 특징점 수 요약 문자열.""" data = self._patterns.get(product_id) if not data: return "" if data.get("method") == "orb": return f"ORB 특징점 {data.get('n_kp', '?')}개 검출됨" return "엣지 NCC 방식 (특징점 부족)" @property def registered_ids(self) -> list: return list(self._patterns.keys()) @property def score_threshold(self) -> float: return self._threshold # ── 공통 유틸 ──────────────────────────────────────────────────────────── # def _to_gray(image: np.ndarray) -> np.ndarray: if len(image.shape) == 3: return cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) return image.copy() def _resize_if_large(image: np.ndarray) -> np.ndarray: """긴 변이 _MAX_IMG_SIZE 초과 시 비율 유지 축소.""" h, w = image.shape[:2] if max(h, w) <= _MAX_IMG_SIZE: return image scale = _MAX_IMG_SIZE / max(h, w) return cv2.resize(image, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA) # ── ORB 특징점 매칭 ────────────────────────────────────────────────────── # def _orb_score(image: np.ndarray, template_des: np.ndarray) -> float: """ ORB 디스크립터로 유사도 점수 계산 (0~100). Lowe's ratio test(0.75)로 good match 필터링. good match _GOOD_MATCH_REF개 이상이면 100점. """ orb = cv2.ORB_create(nfeatures=1000) bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=False) kp2, des2 = orb.detectAndCompute(image, None) if des2 is None or len(kp2) < 2: return 0.0 try: matches = bf.knnMatch(template_des, des2, k=2) except cv2.error: return 0.0 good = [p[0] for p in matches if len(p) == 2 and p[0].distance < 0.75 * p[1].distance] return min(len(good) / _GOOD_MATCH_REF * 100.0, 100.0) # ── 엣지 NCC fallback (특징점 없는 매끄러운 제품용) ───────────────────── # _ROTATION_ANGLES = list(range(-15, 16, 5)) def _to_edges(image: np.ndarray) -> np.ndarray: return cv2.Canny(cv2.GaussianBlur(image, (3, 3), 0), 50, 150) def _rotate_template(image: np.ndarray, angle_deg: float) -> np.ndarray: if abs(angle_deg) < 0.1: return image h, w = image.shape[:2] cx, cy = w / 2.0, h / 2.0 M = cv2.getRotationMatrix2D((cx, cy), angle_deg, 1.0) cos_a, sin_a = abs(M[0, 0]), abs(M[0, 1]) new_w = int(h * sin_a + w * cos_a) new_h = int(h * cos_a + w * sin_a) M[0, 2] += (new_w - w) / 2.0 M[1, 2] += (new_h - h) / 2.0 return cv2.warpAffine(image, M, (new_w, new_h)) def _best_rotated_score(search: np.ndarray, template: np.ndarray) -> float: return max(_ncc_score(search, _rotate_template(template, float(a))) for a in _ROTATION_ANGLES) def _ncc_score(image: np.ndarray, template: np.ndarray) -> float: h, w = image.shape[:2] th, tw = template.shape[:2] if th > h or tw > w: scale = 0.8 * min(h / th, w / tw) template = cv2.resize(template, (max(1, int(tw * scale)), max(1, int(th * scale))), interpolation=cv2.INTER_AREA) th, tw = template.shape[:2] if th > h or tw > w: return 0.0 result = cv2.matchTemplate(image, template, cv2.TM_CCOEFF_NORMED) _, max_val, _, _ = cv2.minMaxLoc(result) return float(max(0.0, max_val)) * 100.0