fix: bg_map per-scala + rimosso cap varianti full-res

Problema: in scenari con molte scale (ring detection), il matcher perdeva
istanze a scale estreme:
1. Cap max_vars_full (default max_matches*8) escludeva la pose corretta
2. bg_map usava box fissa = template_size, penalizzando varianti a scala
   grande dove il template reale è più grande del box

Fix:
- Rimosso cap hard sul numero di varianti full-res (Numba compensa velocità)
- bg_map PER-SCALA: cache {scale: bg_map} con box size scalata
  appropriatamente (tw*scale, th*scale). Calcolato una volta per scala
  unica, poi ogni variante usa il suo bg_map

Benchmark rings_and_nuts (template ruota grande, 3 ruote nella scena a
dimensioni diverse):
  prima:  2/3 match (persa la grande)
  dopo:   3/3 match score 1.0 a scale 1.00, 0.95, 0.80

Regression:
  clip→clip: 13/13 invariato (0.93s)
  ring→clip FP: 3 (era 1 con bg fisso, era 10 senza bg)
  compromesso ragionevole: verify_threshold=0.5 elimina gli ultimi FP

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-24 01:48:40 +02:00
parent faebccb69e
commit 9a0da7aac8
+23 -15
View File
@@ -494,19 +494,25 @@ class LineShapeMatcher:
nms_radius = max(8, min(self.template_size) // 2) nms_radius = max(8, min(self.template_size) // 2)
top_thresh = min_score * self.top_score_factor top_thresh = min_score * self.top_score_factor
# Background score LOCALE: densità media bin-attivi normalizzata su # Background map PER-SCALA: densità media bin attivi normalizzata
# bbox template. Rinormalizzazione rimuove match dove la zona ha # su bbox template scalata. Rinormalizza score per isolare contributo
# attivazioni dense in tutti gli orientamenti (texture/rumore). # non-random e riduce FP in zone con attivazione densa.
tw, th = self.template_size 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: def _bg_for_scale(density: np.ndarray, scale: float,
"""bg_map[y,x] = frazione bin attivi media in bbox template.""" divisor: int) -> np.ndarray:
density = resp.sum(axis=0) # (H, W) bw = max(9, int(round(tw * scale / divisor)))
bw = max(9, tw // scale_div); bh = max(9, th // scale_div) bh = max(9, int(round(th * scale / divisor)))
smooth = cv2.boxFilter(density, cv2.CV_32F, (bw, bh)) sm = cv2.boxFilter(density, cv2.CV_32F, (bw, bh))
return np.clip(smooth / N_BINS, 0.0, 0.99) 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: def _rescore(score: np.ndarray, bg: np.ndarray) -> np.ndarray:
return np.maximum(0.0, (score - bg) / (1.0 - bg + 1e-6)) return np.maximum(0.0, (score - bg) / (1.0 - bg + 1e-6))
@@ -518,7 +524,7 @@ class LineShapeMatcher:
score = self._score_by_shift( score = self._score_by_shift(
resp_top, lvl.dx, lvl.dy, lvl.bin, bin_has_data=bin_has_top, 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 return vi, float(score.max()) if score.size else -1.0
kept_variants: list[tuple[int, float]] = [] kept_variants: list[tuple[int, float]] = []
@@ -536,14 +542,16 @@ class LineShapeMatcher:
if not kept_variants: if not kept_variants:
return [] 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.sort(key=lambda t: -t[1])
kept_variants = kept_variants[:max_vars_full]
# Full-res (parallelizzato per variante) # Full-res (parallelizzato per variante)
resp0 = self._response_map(gray0) resp0 = self._response_map(gray0)
bin_has_full = np.array([resp0[b].any() for b in range(N_BINS)]) 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]: def _full_score(vi: int) -> tuple[int, np.ndarray]:
var = self.variants[vi] var = self.variants[vi]
@@ -551,7 +559,7 @@ class LineShapeMatcher:
score = self._score_by_shift( score = self._score_by_shift(
resp0, lvl0.dx, lvl0.dy, lvl0.bin, bin_has_data=bin_has_full, 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 return vi, score
candidates_per_var: list[tuple[int, np.ndarray]] = [] candidates_per_var: list[tuple[int, np.ndarray]] = []