diff --git a/pm2d/line_matcher.py b/pm2d/line_matcher.py index a7fcb3c..4a4f83d 100644 --- a/pm2d/line_matcher.py +++ b/pm2d/line_matcher.py @@ -494,19 +494,25 @@ class LineShapeMatcher: nms_radius = max(8, min(self.template_size) // 2) top_thresh = min_score * self.top_score_factor - # Background score LOCALE: densità media bin-attivi normalizzata su - # bbox template. Rinormalizzazione rimuove match dove la zona ha - # attivazioni dense in tutti gli orientamenti (texture/rumore). + # Background map PER-SCALA: densità media bin attivi normalizzata + # su bbox template scalata. Rinormalizza score per isolare contributo + # non-random e riduce FP in zone con attivazione densa. tw, th = self.template_size + density_top = resp_top.sum(axis=0) + sf_top = 2 ** top + bg_cache_top: dict[float, np.ndarray] = {} + bg_cache_full: dict[float, np.ndarray] = {} + unique_scales = sorted({var.scale for var in self.variants}) - def _bg_map(resp: np.ndarray, scale_div: int = 1) -> np.ndarray: - """bg_map[y,x] = frazione bin attivi media in bbox template.""" - density = resp.sum(axis=0) # (H, W) - bw = max(9, tw // scale_div); bh = max(9, th // scale_div) - smooth = cv2.boxFilter(density, cv2.CV_32F, (bw, bh)) - return np.clip(smooth / N_BINS, 0.0, 0.99) + def _bg_for_scale(density: np.ndarray, scale: float, + divisor: int) -> np.ndarray: + bw = max(9, int(round(tw * scale / divisor))) + bh = max(9, int(round(th * scale / divisor))) + sm = cv2.boxFilter(density, cv2.CV_32F, (bw, bh)) + return np.clip(sm / N_BINS, 0.0, 0.99) - bg_top = _bg_map(resp_top, scale_div=2 ** top) + for sc in unique_scales: + bg_cache_top[sc] = _bg_for_scale(density_top, sc, sf_top) def _rescore(score: np.ndarray, bg: np.ndarray) -> np.ndarray: return np.maximum(0.0, (score - bg) / (1.0 - bg + 1e-6)) @@ -518,7 +524,7 @@ class LineShapeMatcher: score = self._score_by_shift( resp_top, lvl.dx, lvl.dy, lvl.bin, bin_has_data=bin_has_top, ) - score = _rescore(score, bg_top) + score = _rescore(score, bg_cache_top[var.scale]) return vi, float(score.max()) if score.size else -1.0 kept_variants: list[tuple[int, float]] = [] @@ -536,14 +542,16 @@ class LineShapeMatcher: if not kept_variants: return [] - max_vars_full = max(8, max_matches * 4) + # Cap: tutte le varianti che superano top_thresh passano al full-res. + # Ordinamento per score decrescente (early matches hanno priorità). kept_variants.sort(key=lambda t: -t[1]) - kept_variants = kept_variants[:max_vars_full] # Full-res (parallelizzato per variante) resp0 = self._response_map(gray0) bin_has_full = np.array([resp0[b].any() for b in range(N_BINS)]) - bg_full = _bg_map(resp0, scale_div=1) + density_full = resp0.sum(axis=0) + for sc in unique_scales: + bg_cache_full[sc] = _bg_for_scale(density_full, sc, 1) def _full_score(vi: int) -> tuple[int, np.ndarray]: var = self.variants[vi] @@ -551,7 +559,7 @@ class LineShapeMatcher: score = self._score_by_shift( resp0, lvl0.dx, lvl0.dy, lvl0.bin, bin_has_data=bin_has_full, ) - score = _rescore(score, bg_full) + score = _rescore(score, bg_cache_full[var.scale]) return vi, score candidates_per_var: list[tuple[int, np.ndarray]] = []