import cv2 import json import numpy as np from dataclasses import dataclass, field from pathlib import Path from typing import Literal, Optional, Tuple from PIL import Image FeatureMethod = Literal["orb", "sift", "akaze", "brisk"] DEFAULT_METHOD = "orb" @dataclass class VisionChunk: image: Image.Image feature_method: FeatureMethod = DEFAULT_METHOD keypoints: Optional[list] = field(default=None, init=False) descriptors: Optional[np.ndarray] = field(default=None, init=False) _detector: Optional[cv2.Feature2D] = field(default=None, init=False, repr=False) _matcher: Optional[cv2.DescriptorMatcher] = field(default=None, init=False, repr=False) def _get_detector(self) -> cv2.Feature2D: if self._detector is not None: return self._detector if self.feature_method == "orb": self._detector = cv2.ORB_create( nfeatures=1000, scaleFactor=1.2, nlevels=32, edgeThreshold=31, firstLevel=0, WTA_K=2, patchSize=31, fastThreshold=20, ) elif self.feature_method == "sift": self._detector = cv2.SIFT_create( nfeatures=1500, nOctaveLayers=2, contrastThreshold=0.01, edgeThreshold=15, sigma=3.3 ) elif self.feature_method == "akaze": self._detector = cv2.AKAZE_create( descriptor_type=cv2.AKAZE_DESCRIPTOR_MLDB, descriptor_size=0, descriptor_channels=3, threshold=0.001, nOctaves=4, diffusivity=cv2.KAZE_DIFF_PM_G2 ) elif self.feature_method == "brisk": self._detector = cv2.BRISK_create( thresh=70, octaves=7, patternScale=1.0 ) else: raise ValueError(f"Unsupported feature method: {self.feature_method}") return self._detector def _get_matcher(self) -> cv2.DescriptorMatcher: if self._matcher is None: if self.feature_method == 'sift': self._matcher = cv2.BFMatcher(cv2.NORM_L2, crossCheck=False) else: self._matcher = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=False) return self._matcher def _preprocess(self, img_np: np.ndarray) -> np.ndarray: """CLAHE предобработка для улучшения контраста""" if len(img_np.shape) == 3: gray = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY) else: gray = img_np clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8)) return clahe.apply(gray) def compute_keypoints(self, force: bool = False) -> Tuple[list[cv2.KeyPoint], Optional[np.ndarray]]: if self.keypoints is not None and self.descriptors is not None and not force: return self.keypoints, self.descriptors detector = self._get_detector() # PIL -> OpenCV (RGB->BGR) img_np = np.array(self.image) if img_np.ndim == 3 and img_np.shape[2] == 3: img_np = cv2.cvtColor(img_np, cv2.COLOR_RGB2BGR) # CLAHE предобработка preprocessed = self._preprocess(img_np) keypoints, descriptors = detector.detectAndCompute(preprocessed, None) # Получаем массив response для всех точек responses = np.array([kp.response for kp in keypoints]) # Находим индексы топ-100 top_indices = np.argsort(responses)[-2500:][::-1] # Отбираем keypoints и descriptors best_keypoints = [keypoints[i] for i in top_indices] best_descriptors = descriptors[top_indices] self.keypoints = best_keypoints self.descriptors = best_descriptors return self.keypoints, self.descriptors def detect_and_match_keypoints( self, other: "VisionChunk" ) -> Tuple[ Optional[np.ndarray], Optional[np.ndarray], Optional[list], Optional[list], Optional[list] ]: """ Возвращает: src_pts, dst_pts, good_matches, kp1, kp2 (отцентрированные координаты) """ # Вычисляем keypoints для обоих kp1, des1 = self.compute_keypoints() kp2, des2 = other.compute_keypoints() if des1 is None or des2 is None or len(kp1) < 4 or len(kp2) < 4: return None, None, None, None, None # kNN matching + Lowe ratio test matcher = self._get_matcher() matches_knn = matcher.knnMatch(des1, des2, k=2) good_matches: list[cv2.DMatch] = [] for m_n in matches_knn: if len(m_n) < 2: continue m, n = m_n if m.distance < 0.75 * n.distance: good_matches.append(m) # Фильтрация по расстоянию (мягкий порог 64) good_matches = sorted(good_matches, key=lambda x: x.distance) good_matches = [m for m in good_matches if m.distance < 64] if len(good_matches) < 4: return None, None, None, None, None # Центр изображений img1_cv = self.to_cv2_gray() img2_cv = other.to_cv2_gray() h1, w1 = img1_cv.shape h2, w2 = img2_cv.shape cx1, cy1 = w1 // 2, h1 // 2 cx2, cy2 = w2 // 2, h2 // 2 # Отцентрированные координаты (x_rel, y_rel) src_pts = [] dst_pts = [] for match in good_matches: pt1 = kp1[match.queryIdx].pt src_pts.append([pt1[0] - cx1, cy1 - pt1[1]]) # Y вверх! pt2 = kp2[match.trainIdx].pt dst_pts.append([pt2[0] - cx2, cy2 - pt2[1]]) src_pts = np.float32(src_pts).reshape(-1, 1, 2) dst_pts = np.float32(dst_pts).reshape(-1, 1, 2) return src_pts, dst_pts, good_matches, kp1, kp2 def to_cv2_gray(self) -> np.ndarray: """PIL -> OpenCV grayscale с предобработкой""" img_np = np.array(self.image) if img_np.ndim == 3: gray = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY) else: gray = img_np return self._preprocess(img_np) def get_shape(self) -> Tuple[int, int]: return self.image.height, self.image.width def save_image(self, path: Path | str, format: str = "PNG") -> None: path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) self.image.save(path, format=format.upper()) def to_numpy(self) -> np.ndarray: return np.array(self.image) @classmethod def load_image(cls, path: Path | str, feature_method: FeatureMethod = DEFAULT_METHOD) -> "VisionChunk": path = Path(path) image = Image.open(path) return cls(image=image, feature_method=feature_method)