diff --git a/pm2d/line_matcher.py b/pm2d/line_matcher.py index ce035c6..92187e7 100644 --- a/pm2d/line_matcher.py +++ b/pm2d/line_matcher.py @@ -125,6 +125,11 @@ class _Variant: kw: int cx_local: float # centro-modello dentro al bbox kernel cy_local: float + # Indice template view (X feature - multi-template ensemble). + # 0 = template principale del train(); 1+ = view aggiunte via + # add_template_view(). Usato in _verify_ncc/_compute_recall per + # scegliere il template gray corretto per match. + view_idx: int = 0 class LineShapeMatcher: @@ -170,6 +175,11 @@ class LineShapeMatcher: self.template_gray: np.ndarray | None = None # Maschera usata in training (propagata al refine per coerenza). self._train_mask: np.ndarray | None = None + # Multi-template ensemble (X feature): N view dello stesso pezzo + # (chiari/scuri, condizioni diverse). Template principale e' [0], + # view aggiunte via add_template_view() sono [1+]. Match restituisce + # la view che ha matchato meglio. + self._view_templates: list[tuple[np.ndarray, np.ndarray | None]] = [] # --- Helpers ------------------------------------------------------- @@ -314,8 +324,60 @@ class LineShapeMatcher: self._train_mask = mask_full.copy() self.variants.clear() + # Reset view list: template principale = view 0 + self._view_templates = [(gray.copy(), mask_full.copy())] # Invalida cache feature di refine: il template e cambiato. self._refine_feat_cache = {} + self._build_variants_for_view(gray, mask_full, view_idx=0) + self._dedup_variants() + return len(self.variants) + + def add_template_view( + self, template_bgr: np.ndarray, mask: np.ndarray | None = None, + ) -> int: + """Aggiunge una view template extra all'ensemble (Halcon-style + create_aniso_shape_model con fusione N viste). + + Genera varianti del nuovo template con stessi parametri (range + angle/scale) e le APPENDE a self.variants. NCC/recall usano + automaticamente il template della view che ha matchato. + + Use case: pezzo che cambia aspetto (chiaro/scuro, prima/dopo + trattamento, illuminazioni diverse) → un solo matcher resistente. + + Ritorna numero TOTALE varianti dopo l'aggiunta. Le view sono + indicizzate da 1 in poi (0 e' il template del train). + """ + if not self.variants: + raise RuntimeError( + "Chiamare train(template_principale) prima di add_template_view") + gray = self._to_gray(template_bgr) + h, w = gray.shape + if (w, h) != self.template_size: + # Resize per coerenza con bbox/poly + gray = cv2.resize(gray, self.template_size, interpolation=cv2.INTER_LINEAR) + if mask is not None: + mask = cv2.resize(mask, self.template_size, interpolation=cv2.INTER_NEAREST) + if mask is None: + mask_full = np.full(gray.shape, 255, dtype=np.uint8) + else: + mask_full = (mask > 0).astype(np.uint8) * 255 + view_idx = len(self._view_templates) + self._view_templates.append((gray.copy(), mask_full.copy())) + n_before = len(self.variants) + self._build_variants_for_view(gray, mask_full, view_idx=view_idx) + self._dedup_variants() + return len(self.variants) - n_before + + def _build_variants_for_view( + self, gray: np.ndarray, mask_full: np.ndarray, view_idx: int, + ) -> None: + """Estrae varianti rotate+scalate per UNA view template. + + Estrazione algorithm identica al train() originale, separato per + riuso da add_template_view (multi-template ensemble). + """ + h, w = gray.shape for s in self._scale_list(): sw = max(16, int(round(w * s))) sh = max(16, int(round(h * s))) @@ -369,9 +431,8 @@ class LineShapeMatcher: levels=levels, kh=kh, kw=kw, cx_local=float(cx_local), cy_local=float(cy_local), + view_idx=view_idx, )) - self._dedup_variants() - return len(self.variants) def _dedup_variants(self) -> int: """Rimuove varianti con feature-set identico (post-quantizzazione). @@ -740,9 +801,23 @@ class LineShapeMatcher: s2, cx2, cy2 = _score_at_angle(x2) return best + def _get_view_template( + self, view_idx: int, + ) -> tuple[np.ndarray | None, np.ndarray | None]: + """Ritorna (template_gray, mask) per la view specificata. + + view_idx 0 = template principale (train), 1+ = view extra + aggiunte via add_template_view. Usato per scegliere il template + corretto in NCC/recall verification quando il matcher e' + ensemble multi-template. + """ + if 0 <= view_idx < len(self._view_templates): + return self._view_templates[view_idx] + return self.template_gray, self._train_mask + def _verify_ncc( self, scene_gray: np.ndarray, cx: float, cy: float, - angle_deg: float, scale: float, + angle_deg: float, scale: float, view_idx: int = 0, ) -> float: """NCC tra template warpato alla pose e scena sottostante. @@ -754,9 +829,9 @@ class LineShapeMatcher: il matcher linemod può dare score alto su texture generiche ma sovrapponendo il template gray i pixel non corrispondono. """ - if self.template_gray is None: + t, train_mask = self._get_view_template(view_idx) + if t is None: return 1.0 - t = self.template_gray h, w = t.shape cx_t = (w - 1) / 2.0 cy_t = (h - 1) / 2.0 @@ -781,8 +856,8 @@ class LineShapeMatcher: t, M, (cw, ch), flags=cv2.INTER_LINEAR, borderValue=0, ) - if self._train_mask is not None: - mask_src = self._train_mask + if train_mask is not None: + mask_src = train_mask else: mask_src = np.full_like(t, 255) mask_w = cv2.warpAffine( @@ -1185,7 +1260,10 @@ class LineShapeMatcher: # ranking/visualizzazione (uno score 1.0 vero richiede sia # match shape sia template gray identici). if verify_ncc and float(score_f) < ncc_skip_above: - ncc = self._verify_ncc(gray0, cx_f, cy_f, ang_f, var.scale) + ncc = self._verify_ncc( + gray0, cx_f, cy_f, ang_f, var.scale, + view_idx=getattr(var, "view_idx", 0), + ) if ncc < verify_threshold: continue score_f = (float(score_f) + max(0.0, ncc)) * 0.5