From cc811fdc94cba6ba72075aa6e94b54a4331b386d Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 12 Jun 2026 11:54:24 +0000 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20precisione=20rotazione=20sub-0.1?= =?UTF-8?q?=C2=B0=20+=20refine=20least-squares=20+=20propagate=20windowed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause rotazione imprecisa: score saturo sulla spread bitmap dilatata (raggio 4-5) -> refine senza gradiente (angolo restava quantizzato allo step) e minMaxLoc sul plateau spostava il centro sull'angolo finestra (errore sistematico 3*sqrt(2) px). - _refine_angle: ottimizza su bitmap fine raggio 1 (spread_fine, in cache scena), picco sub-pixel con centroide plateau, score finale ricalcolato su spread coarse (semantica soglie invariata) - _subpixel_refine_lm riscritto: snap edge sub-pixel lungo la normale + LSQ 3x3 (dx, dy, dtheta), ON di default, Sobel scena precomputato - _prepare_padded_template: centro rotazione coerente col padding - round invece di truncation sugli offset feature (bias 0.25px) - _angle_list include estremo superiore del range - _refine_pose_joint rimosso (NM su funzione a gradini, terminava subito) - pyramid_propagate default ON con kernel windowed (le feature campionano l'intera scena: il crop precedente le troncava -> score 0), picchi = massimi locali, auto-off per template elongati >2:1 - piramide 3 livelli default con clamp su dimensione template - cache scena: hash dell'intera immagine (64KB collidevano) GT sintetica 7 pose: errore angolo 2.3->0.05 deg, posizione 4.2->0.04 px. Suite 16 scenari: match >= baseline, totale find -13%. Co-Authored-By: Claude Fable 5 --- pm2d/_jit_kernels.py | 144 +++++++++ pm2d/line_matcher.py | 681 +++++++++++++++++++++++-------------------- 2 files changed, 507 insertions(+), 318 deletions(-) diff --git a/pm2d/_jit_kernels.py b/pm2d/_jit_kernels.py index b504819..d1e7069 100644 --- a/pm2d/_jit_kernels.py +++ b/pm2d/_jit_kernels.py @@ -271,6 +271,108 @@ if HAS_NUMBA: acc[y, x] = 0.0 return acc + @nb.njit(cache=True, parallel=True, fastmath=True, boundscheck=False) + def _jit_score_bitmap_rescored_window( + spread: np.ndarray, # uint8 (H, W) - scena INTERA + dx: np.ndarray, dy: np.ndarray, bins: np.ndarray, + bit_active: np.uint8, + bg: np.ndarray, # float32 (H, W) - scena intera + y0: nb.int64, x0: nb.int64, + wh: nb.int64, ww: nb.int64, + ) -> np.ndarray: + """Score rescored valutato SOLO nella finestra (y0, x0, wh, ww). + + Le feature campionano lo spread dell'intera scena (bounds-checked + sui bordi scena): a differenza di chiamare il kernel su un crop, + le feature che escono dalla finestra NON contano come miss. + Usato dal path pyramid_propagate: costo ∝ area finestra. + """ + H, W = spread.shape + N = dx.shape[0] + acc = np.zeros((wh, ww), dtype=np.float32) + for yi in nb.prange(wh): + y = y0 + yi + for i in range(N): + b = bins[i] + mask = np.uint8(1) << b + if (bit_active & mask) == 0: + continue + yy = y + dy[i] + if yy < 0 or yy >= H: + continue + ddx = dx[i] + xi_lo = 0 + xi_hi = ww + lo = -(x0 + ddx) + if lo > xi_lo: + xi_lo = lo + hi = W - (x0 + ddx) + if hi < xi_hi: + xi_hi = hi + for xi in range(xi_lo, xi_hi): + if spread[yy, x0 + xi + ddx] & mask: + acc[yi, xi] += 1.0 + if N > 0: + inv = 1.0 / N + for yi in nb.prange(wh): + for xi in range(ww): + v = acc[yi, xi] * inv + bgv = bg[y0 + yi, x0 + xi] + if bgv < 1.0: + r = (v - bgv) / (1.0 - bgv + 1e-6) + acc[yi, xi] = r if r > 0.0 else 0.0 + else: + acc[yi, xi] = 0.0 + return acc + + @nb.njit(cache=True, parallel=True, fastmath=True, boundscheck=False) + def _jit_score_bitmap_rescored_window_u16( + spread: np.ndarray, # uint16 (H, W) + dx: np.ndarray, dy: np.ndarray, bins: np.ndarray, + bit_active: np.uint16, + bg: np.ndarray, + y0: nb.int64, x0: nb.int64, + wh: nb.int64, ww: nb.int64, + ) -> np.ndarray: + """Versione uint16 (polarity 16-bin) del kernel windowed.""" + H, W = spread.shape + N = dx.shape[0] + acc = np.zeros((wh, ww), dtype=np.float32) + for yi in nb.prange(wh): + y = y0 + yi + for i in range(N): + b = bins[i] + mask = np.uint16(1) << b + if (bit_active & mask) == 0: + continue + yy = y + dy[i] + if yy < 0 or yy >= H: + continue + ddx = dx[i] + xi_lo = 0 + xi_hi = ww + lo = -(x0 + ddx) + if lo > xi_lo: + xi_lo = lo + hi = W - (x0 + ddx) + if hi < xi_hi: + xi_hi = hi + for xi in range(xi_lo, xi_hi): + if spread[yy, x0 + xi + ddx] & mask: + acc[yi, xi] += 1.0 + if N > 0: + inv = 1.0 / N + for yi in nb.prange(wh): + for xi in range(ww): + v = acc[yi, xi] * inv + bgv = bg[y0 + yi, x0 + xi] + if bgv < 1.0: + r = (v - bgv) / (1.0 - bgv + 1e-6) + acc[yi, xi] = r if r > 0.0 else 0.0 + else: + acc[yi, xi] = 0.0 + return acc + @nb.njit(cache=True, parallel=True, fastmath=True, boundscheck=False) def _jit_top_max_per_variant( spread: np.ndarray, # uint8 (H, W) @@ -426,6 +528,9 @@ if HAS_NUMBA: _jit_top_max_per_variant( spread, dx, dy, b, offsets, np.uint8(0xFF), bg_pv, scale_idx, ) + _jit_score_bitmap_rescored_window( + spread, dx, dy, b, np.uint8(0xFF), bg, 4, 4, 8, 8, + ) _jit_popcount_density(spread) spread16 = np.zeros((32, 32), dtype=np.uint16) _jit_score_bitmap_rescored_u16( @@ -447,6 +552,12 @@ else: # pragma: no cover def _jit_score_bitmap_rescored_strided(spread, dx, dy, bins, bit_active, bg, stride): raise RuntimeError("numba non disponibile") + def _jit_score_bitmap_rescored_window(spread, dx, dy, bins, bit_active, bg, y0, x0, wh, ww): + raise RuntimeError("numba non disponibile") + + def _jit_score_bitmap_rescored_window_u16(spread, dx, dy, bins, bit_active, bg, y0, x0, wh, ww): + raise RuntimeError("numba non disponibile") + def _jit_score_bitmap_greedy(spread, dx, dy, bins, bit_active, min_score, greediness): raise RuntimeError("numba non disponibile") @@ -524,6 +635,39 @@ def score_bitmap_rescored( return np.maximum(0.0, out).astype(np.float32) +def score_bitmap_rescored_window( + spread: np.ndarray, dx: np.ndarray, dy: np.ndarray, bins: np.ndarray, + bit_active: int, bg: np.ndarray, + y0: int, x0: int, wh: int, ww: int, +) -> np.ndarray: + """Score rescored solo nella finestra (y0, x0, wh, ww) della scena. + + Le feature campionano l'INTERA scena: feature fuori finestra ma dentro + scena contano correttamente (chiamare il kernel su un crop le tratta + come miss e azzera lo score — il bug che rendeva inutilizzabile il + path pyramid_propagate). Fallback no-numba: kernel pieno + slice. + """ + if HAS_NUMBA and len(dx) > 0: + dx_c = np.ascontiguousarray(dx, dtype=np.int32) + dy_c = np.ascontiguousarray(dy, dtype=np.int32) + bins_c = np.ascontiguousarray(bins, dtype=np.int8) + bg_c = np.ascontiguousarray(bg, dtype=np.float32) + if spread.dtype == np.uint16: + return _jit_score_bitmap_rescored_window_u16( + np.ascontiguousarray(spread, dtype=np.uint16), + dx_c, dy_c, bins_c, np.uint16(bit_active), bg_c, + int(y0), int(x0), int(wh), int(ww), + ) + return _jit_score_bitmap_rescored_window( + np.ascontiguousarray(spread, dtype=np.uint8), + dx_c, dy_c, bins_c, np.uint8(bit_active), bg_c, + int(y0), int(x0), int(wh), int(ww), + ) + # Fallback (lento, solo senza numba): score full-frame + slice finestra + full = score_bitmap_rescored(spread, dx, dy, bins, bit_active, bg) + return full[y0:y0 + wh, x0:x0 + ww] + + def score_bitmap_greedy( spread: np.ndarray, dx: np.ndarray, dy: np.ndarray, bins: np.ndarray, bit_active: int, min_score: float, greediness: float, diff --git a/pm2d/line_matcher.py b/pm2d/line_matcher.py index 15967bd..543e0c6 100644 --- a/pm2d/line_matcher.py +++ b/pm2d/line_matcher.py @@ -40,6 +40,7 @@ from pm2d._jit_kernels import ( score_by_shift as _jit_score_by_shift, score_bitmap as _jit_score_bitmap, score_bitmap_rescored as _jit_score_bitmap_rescored, + score_bitmap_rescored_window as _jit_score_bitmap_rescored_window, score_bitmap_greedy as _jit_score_bitmap_greedy, top_max_per_variant as _jit_top_max_per_variant, popcount_density as _jit_popcount, @@ -172,7 +173,7 @@ class LineShapeMatcher: scale_step: float = 0.1, spread_radius: int = 4, min_feature_spacing: int = 3, - pyramid_levels: int = 2, + pyramid_levels: int = 3, top_score_factor: float = 0.5, n_threads: int | None = None, use_polarity: bool = False, @@ -473,8 +474,46 @@ class LineShapeMatcher: step = self._effective_angle_step() if step <= 0 or a0 >= a1: return [float(a0)] - n = int(np.floor((a1 - a0) / step)) - return [float(a0 + i * step) for i in range(n)] + # Include l'estremo superiore: con range parziali (es. ±15°) il + # +15° deve essere testato quanto il -15°. Se il range copre 360° + # interi l'estremo coincide con a0 (mod 360) e viene escluso per + # non duplicare la variante. + n = int(np.floor((a1 - a0) / step + 1e-9)) + 1 + angles = [float(a0 + i * step) for i in range(n)] + if a1 - a0 >= 360.0: + angles = [a for a in angles if a - a0 < 360.0 - 1e-9] + return angles + + def _prepare_padded_template( + self, template_gray: np.ndarray, mask_full: np.ndarray, scale: float, + ) -> tuple[np.ndarray, np.ndarray, tuple[float, float], int]: + """Scala + padda template e mask; ritorna (gray_p, mask_p, center, diag). + + `center` e' il centro REALE del template dentro l'immagine paddata + (px + sw/2, py + sh/2): con padding floor differisce da diag/2 fino + a 0.5 px. Ruotare attorno a diag/2 (come si faceva prima) faceva + orbitare il centro-modello attorno al centro di rotazione, con un + bias di posizione dipendente dall'angolo. Tutti i percorsi che + ricostruiscono il template ruotato devono usare QUESTO helper. + """ + h, w = template_gray.shape + sw = max(16, int(round(w * scale))) + sh = max(16, int(round(h * scale))) + gray_s = cv2.resize(template_gray, (sw, sh), interpolation=cv2.INTER_LINEAR) + mask_s = cv2.resize(mask_full, (sw, sh), interpolation=cv2.INTER_NEAREST) + diag = int(np.ceil(np.hypot(sh, sw))) + 6 + py = (diag - sh) // 2 + px = (diag - sw) // 2 + gray_p = cv2.copyMakeBorder( + gray_s, py, diag - sh - py, px, diag - sw - px, + cv2.BORDER_REPLICATE, + ) + mask_p = cv2.copyMakeBorder( + mask_s, py, diag - sh - py, px, diag - sw - px, + cv2.BORDER_CONSTANT, value=0, + ) + center = (px + sw / 2.0, py + sh / 2.0) + return gray_p, mask_p, center, diag # --- Training ------------------------------------------------------ @@ -504,6 +543,13 @@ class LineShapeMatcher: h, w = gray.shape self.template_size = (w, h) self.template_gray = gray.copy() + # Clamp livelli piramide alla dimensione template: al top-level il + # lato minimo deve restare >= 12 px, sotto le feature collassano + # tutte negli stessi (dx,dy) e lo score top diventa rumore. + max_lv = 1 + while min(w, h) / (2 ** max_lv) >= 12 and max_lv < 4: + max_lv += 1 + self.pyramid_levels = max(1, min(self.pyramid_levels, max_lv)) if mask is None: mask_full = np.full((h, w), 255, dtype=np.uint8) else: @@ -566,24 +612,10 @@ class LineShapeMatcher: 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))) - gray_s = cv2.resize(gray, (sw, sh), interpolation=cv2.INTER_LINEAR) - mask_s = cv2.resize(mask_full, (sw, sh), interpolation=cv2.INTER_NEAREST) - diag = int(np.ceil(np.hypot(sh, sw))) + 6 - py = (diag - sh) // 2 - px = (diag - sw) // 2 - gray_p = cv2.copyMakeBorder( - gray_s, py, diag - sh - py, px, diag - sw - px, - cv2.BORDER_REPLICATE, + gray_p, mask_p, center, diag = self._prepare_padded_template( + gray, mask_full, s, ) - mask_p = cv2.copyMakeBorder( - mask_s, py, diag - sh - py, px, diag - sw - px, - cv2.BORDER_CONSTANT, value=0, - ) - center = (diag / 2.0, diag / 2.0) for ang in self._angle_list(): M = cv2.getRotationMatrix2D(center, ang, 1.0) @@ -600,10 +632,10 @@ class LineShapeMatcher: if len(fx) < 8: continue - cx_c = diag / 2.0 - cy_c = diag / 2.0 - dx = (fx - cx_c).astype(np.int32) - dy = (fy - cy_c).astype(np.int32) + # round (non truncation): astype(int32) tronca verso zero + # e introduceva un bias sistematico ~0.25 px verso il centro. + dx = np.round(fx - center[0]).astype(np.int32) + dy = np.round(fy - center[1]).astype(np.int32) x0 = int(dx.min()); x1 = int(dx.max()) y0 = int(dy.min()); y1 = int(dy.max()) @@ -687,8 +719,13 @@ class LineShapeMatcher: try: import hashlib h = hashlib.md5() - sample = gray.tobytes()[:65536] - h.update(sample) + # Hash dell'INTERA scena: hashare solo i primi 64KB (prime + # ~80 righe a 830px) faceva collidere scene con la stessa + # banda superiore (es. sfondo uniforme da camera fissa) → + # find() ritornava i risultati della scena sbagliata. + # tobytes() copiava gia' tutto il buffer, il costo extra + # dell'md5 completo e' ~1ms. + h.update(gray.tobytes()) h.update(f"|{gray.shape}|{gray.dtype}".encode()) h.update( f"|{self.weak_grad}|{self.strong_grad}" @@ -717,11 +754,17 @@ class LineShapeMatcher: while len(self._scene_cache) > self._SCENE_CACHE_SIZE: self._scene_cache.popitem(last=False) - def _spread_bitmap(self, gray: np.ndarray) -> np.ndarray: + def _spread_bitmap( + self, gray: np.ndarray, radius: int | None = None, + ) -> np.ndarray: """Spread bitmap: bit b acceso dove bin b è presente nel raggio. dtype: uint8 per N_BINS=8, uint16 per N_BINS_POL=16 (use_polarity). Se use_gpu=True: Sobel + dilate via cv2.UMat (OpenCL kernel GPU). + radius: override del raggio di spread (default self.spread_radius). + radius=0/1 produce una bitmap "fine" senza tolleranza, usata nel + refine finale: sulla bitmap dilatata lo score satura e il refine + non distingue pose entro ±spread_radius px / ±atan(spread/R) gradi. """ if self.use_gpu and not isinstance(gray, cv2.UMat): gray_in = cv2.UMat(np.ascontiguousarray(gray)) @@ -729,7 +772,8 @@ class LineShapeMatcher: gray_in = gray mag, bins = self._gradient(gray_in) valid = mag >= self.weak_grad - k = 2 * self.spread_radius + 1 + r = self.spread_radius if radius is None else max(0, int(radius)) + k = 2 * r + 1 kernel = np.ones((k, k), dtype=np.uint8) H, W = (gray.shape if isinstance(gray, np.ndarray) else (gray.get().shape[0], gray.get().shape[1])) @@ -755,7 +799,9 @@ class LineShapeMatcher: if not bin_present[b]: continue # XX: nessun pixel di questo bin sopra weak_grad mask_b = ((bins == b) & valid).astype(np.uint8) - if self.use_gpu: + if r == 0: + d_np = mask_b + elif self.use_gpu: d = cv2.dilate(cv2.UMat(mask_b), kernel) d_np = d.get() else: @@ -828,111 +874,9 @@ class LineShapeMatcher: oy = float(np.clip(oy, -0.5, 0.5)) return x + ox, y + oy - def _refine_pose_joint( - self, - spread0: np.ndarray, - template_gray: np.ndarray, - cx: float, cy: float, - angle_deg: float, scale: float, - mask_full: np.ndarray, - max_iter: int = 24, - tol: float = 1e-3, - ) -> tuple[float, float, float, float]: - """Refine congiunto (cx, cy, angle) via Nelder-Mead 3D. - - Ottimizza simultaneamente posizione e angolo (vs golden search 1D - sull'angolo poi quadratico 2D su xy che alterna assi). Halcon-style: - un singolo iter LM stila il match a precisione sub-pixel + sub-step. - Ritorna (angle, score, cx, cy) dove score e quello calcolato sulla - scena spread (no template gray). - """ - h, w = template_gray.shape - sw = max(16, int(round(w * scale))) - sh = max(16, int(round(h * scale))) - gray_s = cv2.resize(template_gray, (sw, sh), interpolation=cv2.INTER_LINEAR) - mask_s = cv2.resize(mask_full, (sw, sh), interpolation=cv2.INTER_NEAREST) - diag = int(np.ceil(np.hypot(sh, sw))) + 6 - py = (diag - sh) // 2; px = (diag - sw) // 2 - gray_p = cv2.copyMakeBorder(gray_s, py, diag - sh - py, px, diag - sw - px, - cv2.BORDER_REPLICATE) - mask_p = cv2.copyMakeBorder(mask_s, py, diag - sh - py, px, diag - sw - px, - cv2.BORDER_CONSTANT, value=0) - center = (diag / 2.0, diag / 2.0) - H, W = spread0.shape - - def _score(params: tuple[float, float, float]) -> float: - ddx, ddy, dang = params - ang = angle_deg + dang - M = cv2.getRotationMatrix2D(center, ang, 1.0) - gray_r = cv2.warpAffine(gray_p, M, (diag, diag), - flags=cv2.INTER_LINEAR, - borderMode=cv2.BORDER_REPLICATE) - mask_r = cv2.warpAffine(mask_p, M, (diag, diag), - flags=cv2.INTER_NEAREST, borderValue=0) - mag, bins = self._gradient(gray_r) - fx, fy, fb = self._extract_features(mag, bins, mask_r) - if len(fx) < 8: - return 0.0 - cxe = cx + ddx; cye = cy + ddy - ix = int(round(cxe)); iy = int(round(cye)) - tot = 0 - valid = 0 - for i in range(len(fx)): - xs = ix + int(fx[i] - center[0]) - ys = iy + int(fy[i] - center[1]) - if 0 <= xs < W and 0 <= ys < H: - bit = np.uint8(1 << int(fb[i])) - if spread0[ys, xs] & bit: - tot += 1 - valid += 1 - return -float(tot) / max(1, valid) # minimize -score - - # Nelder-Mead 3D inline (no scipy). Simplex iniziale: vertice + offset - # dx=±0.5px, dy=±0.5px, dθ=±step/2. - step_a = self.angle_step_deg / 2.0 if self.angle_step_deg > 0 else 1.0 - x0 = np.array([0.0, 0.0, 0.0]) - simplex = np.array([ - x0, - x0 + [0.5, 0.0, 0.0], - x0 + [0.0, 0.5, 0.0], - x0 + [0.0, 0.0, step_a], - ]) - fvals = np.array([_score(tuple(s)) for s in simplex]) - for _ in range(max_iter): - order = np.argsort(fvals) - simplex = simplex[order]; fvals = fvals[order] - if abs(fvals[-1] - fvals[0]) < tol: - break - centroid = simplex[:-1].mean(axis=0) - xr = centroid + 1.0 * (centroid - simplex[-1]) - fr = _score(tuple(xr)) - if fvals[0] <= fr < fvals[-2]: - simplex[-1] = xr; fvals[-1] = fr - continue - if fr < fvals[0]: - xe = centroid + 2.0 * (centroid - simplex[-1]) - fe = _score(tuple(xe)) - if fe < fr: - simplex[-1] = xe; fvals[-1] = fe - else: - simplex[-1] = xr; fvals[-1] = fr - continue - xc = centroid + 0.5 * (simplex[-1] - centroid) - fc = _score(tuple(xc)) - if fc < fvals[-1]: - simplex[-1] = xc; fvals[-1] = fc - continue - for k in range(1, 4): - simplex[k] = simplex[0] + 0.5 * (simplex[k] - simplex[0]) - fvals[k] = _score(tuple(simplex[k])) - best_i = int(np.argmin(fvals)) - ddx, ddy, dang = simplex[best_i] - return (angle_deg + float(dang), -float(fvals[best_i]), - cx + float(ddx), cy + float(ddy)) - def _refine_angle( self, - spread0: np.ndarray, # bitmap uint8 (H, W) + spread0: np.ndarray, # bitmap uint8/uint16 (H, W) - spread pieno bit_active: int, template_gray: np.ndarray, cx: float, cy: float, @@ -941,33 +885,31 @@ class LineShapeMatcher: angle_fine_step: float = 0.5, search_radius: float | None = None, original_score: float | None = None, + spread_fine: np.ndarray | None = None, ) -> tuple[float, float, float, float]: """Ricerca angolare fine (sub-step) attorno al match grezzo. - Genera 5 template temporanei a angle ± {0.5, 1.0} * step e sceglie - l'angolo con score massimo (parabolic fit sulle 3 score centrali). + Golden-section sull'angolo + argmax posizione in finestra ±3 px. Ritorna (angle_refined, score, cx_refined, cy_refined). + + L'ottimizzazione gira sulla bitmap FINE (spread_fine, raggio 1): + sulla bitmap dilatata (spread0, raggio 4-5) lo score satura a 1.0 + per qualunque posa entro ±spread px / ±atan(spread/R) gradi e il + refine non vede alcun gradiente (l'angolo restava quello grezzo + quantizzato e cv2.minMaxLoc sul plateau saturo spostava il centro + sull'angolo in alto a sinistra della finestra: errore misurato + 3·sqrt(2) ≈ 4.24 px). Lo score RITORNATO e' ricalcolato alla posa + raffinata su spread0, per mantenere la semantica precedente + (tolleranza spread_radius) su soglie/min_score. """ - # NB: rimosso early-skip su score >= 0.99. Lo score linemod/shape - # satura facilmente a 1.0 (specie con pyramid_propagate o spread - # ampio) ma NON garantisce angolo preciso: l'angolo grezzo della - # variante e' quantizzato a multipli di angle_step (5 deg default). - # Refine angolare e' essenziale per orientamento sub-step. if search_radius is None: search_radius = self._effective_angle_step() + # Bitmap su cui ottimizzare: fine se disponibile, altrimenti spread0. + opt_map = spread_fine if spread_fine is not None else spread0 - h, w = template_gray.shape - sw = max(16, int(round(w * scale))) - sh = max(16, int(round(h * scale))) - gray_s = cv2.resize(template_gray, (sw, sh), interpolation=cv2.INTER_LINEAR) - mask_s = cv2.resize(mask_full, (sw, sh), interpolation=cv2.INTER_NEAREST) - diag = int(np.ceil(np.hypot(sh, sw))) + 6 - py = (diag - sh) // 2; px = (diag - sw) // 2 - gray_p = cv2.copyMakeBorder(gray_s, py, diag - sh - py, px, diag - sw - px, - cv2.BORDER_REPLICATE) - mask_p = cv2.copyMakeBorder(mask_s, py, diag - sh - py, px, diag - sw - px, - cv2.BORDER_CONSTANT, value=0) - center = (diag / 2.0, diag / 2.0) + gray_p, mask_p, center, diag = self._prepare_padded_template( + template_gray, mask_full, scale, + ) H, W = spread0.shape margin = 3 @@ -982,35 +924,44 @@ class LineShapeMatcher: feat_cache = self._refine_feat_cache cache_scale_key = round(scale * 1000) - def _score_at_angle(off: float) -> tuple[float, float, float]: - """Ritorna (score, best_cx, best_cy) per angolo = angle_deg + off.""" - ang = angle_deg + off + def _feats_at_angle(ang: float): ck = (round(ang * 20), cache_scale_key) cached = feat_cache.get(ck) if cached is not None: - fx, fy, fb = cached - else: - M = cv2.getRotationMatrix2D(center, ang, 1.0) - gray_r = cv2.warpAffine(gray_p, M, (diag, diag), - flags=cv2.INTER_LINEAR, - borderMode=cv2.BORDER_REPLICATE) - mask_r = cv2.warpAffine(mask_p, M, (diag, diag), - flags=cv2.INTER_NEAREST, borderValue=0) - mag, bins = self._gradient(gray_r) - fx, fy, fb = self._extract_features(mag, bins, mask_r) - # LRU semplice: limita cache a ~256 angoli (8 angoli * 32 candidati) - if len(feat_cache) > 256: - feat_cache.pop(next(iter(feat_cache))) - feat_cache[ck] = (fx, fy, fb) + return cached + M = cv2.getRotationMatrix2D(center, ang, 1.0) + gray_r = cv2.warpAffine(gray_p, M, (diag, diag), + flags=cv2.INTER_LINEAR, + borderMode=cv2.BORDER_REPLICATE) + mask_r = cv2.warpAffine(mask_p, M, (diag, diag), + flags=cv2.INTER_NEAREST, borderValue=0) + mag, bins = self._gradient(gray_r) + fx, fy, fb = self._extract_features(mag, bins, mask_r) + # LRU semplice: limita cache a ~256 angoli (8 angoli * 32 candidati) + if len(feat_cache) > 256: + feat_cache.pop(next(iter(feat_cache))) + feat_cache[ck] = (fx, fy, fb) + return fx, fy, fb + + def _score_at_angle(off: float) -> tuple[float, float, float]: + """Ritorna (score, best_cx, best_cy) per angolo = angle_deg + off. + + Score = max su finestra ±margin px attorno a (cx, cy) sulla + bitmap di ottimizzazione; posizione = picco sub-pixel della + finestra (centroide plateau / fit quadratico, NON minMaxLoc + che sul plateau e' biased verso l'angolo top-left). + """ + ang = angle_deg + off + fx, fy, fb = _feats_at_angle(ang) if len(fx) < 8: return (0.0, cx, cy) - dx = (fx - center[0]).astype(np.int32) - dy = (fy - center[1]).astype(np.int32) + dx = np.round(fx - center[0]).astype(np.int32) + dy = np.round(fy - center[1]).astype(np.int32) y_lo = int(cy) - margin; y_hi = int(cy) + margin + 1 x_lo = int(cx) - margin; x_hi = int(cx) + margin + 1 sh_w = y_hi - y_lo; sw_w = x_hi - x_lo acc = np.zeros((sh_w, sw_w), dtype=np.float32) - spread_dtype = spread0.dtype.type + spread_dtype = opt_map.dtype.type for i in range(len(dx)): ddx = int(dx[i]); ddy = int(dy[i]); b = int(fb[i]) bit = spread_dtype(1 << b) @@ -1021,14 +972,19 @@ class LineShapeMatcher: s_y0 = max(0, sy0); s_y1 = min(H, sy1) s_x0 = max(0, sx0); s_x1 = min(W, sx1) if s_y1 > s_y0 and s_x1 > s_x0: - region = spread0[s_y0:s_y1, s_x0:s_x1] + region = opt_map[s_y0:s_y1, s_x0:s_x1] acc[a_y0:a_y1, a_x0:a_x1] += ( (region & bit) != 0 ).astype(np.float32) acc /= len(dx) _, max_val, _, max_loc = cv2.minMaxLoc(acc) - return (float(max_val), - float(x_lo + max_loc[0]), float(y_lo + max_loc[1])) + if max_val <= 0.0: + return (0.0, cx, cy) + # Picco sub-pixel dentro la finestra (gestisce plateau e fit 3x3) + px_f, py_f = self._subpixel_peak( + acc, int(max_loc[0]), int(max_loc[1]), plateau_radius=margin, + ) + return (float(max_val), float(x_lo + px_f), float(y_lo + py_f)) # Golden-section search su [-search_radius, +search_radius]: # converge in log tempo a precisione ~0.1°, ~8 valutazioni vs 5 @@ -1064,7 +1020,25 @@ class LineShapeMatcher: x1 = x2; s1 = s2; cx1 = cx2; cy1 = cy2 x2 = a_lo + _GOLDEN * (a_hi - a_lo) s2, cx2, cy2 = _score_at_angle(x2) - return best + + ang_best, s_best, cx_best, cy_best = best + if spread_fine is None: + return best + # Score finale alla posa raffinata sullo spread COARSE: stessa + # semantica dello score pre-refine (tolleranza spread_radius), + # cosi' min_score/verify mantengono il significato di prima. + fx, fy, fb = _feats_at_angle(ang_best) + if len(fx) < 8: + return best + xs = np.round(fx - center[0]).astype(np.int32) + int(round(cx_best)) + ys = np.round(fy - center[1]).astype(np.int32) + int(round(cy_best)) + ok = (xs >= 0) & (xs < W) & (ys >= 0) & (ys < H) + if not ok.any(): + return (ang_best, 0.0, cx_best, cy_best) + bits = spread0[ys[ok], xs[ok]].astype(np.int32) + hit = (bits & np.left_shift(1, fb[ok].astype(np.int32))) != 0 + score_coarse = float(hit.sum()) / len(fx) + return (ang_best, score_coarse, cx_best, cy_best) def _get_view_template( self, view_idx: int, @@ -1089,26 +1063,13 @@ class LineShapeMatcher: """ if self.template_gray is None: return 1.0 - h, w = self.template_gray.shape - scale = variant.scale - sw = max(16, int(round(w * scale))) - sh = max(16, int(round(h * scale))) - gray_s = cv2.resize(self.template_gray, (sw, sh), interpolation=cv2.INTER_LINEAR) mask_src = ( self._train_mask if self._train_mask is not None else np.full_like(self.template_gray, 255) ) - mask_s = cv2.resize(mask_src, (sw, sh), interpolation=cv2.INTER_NEAREST) - diag = int(np.ceil(np.hypot(sh, sw))) + 6 - py = (diag - sh) // 2; px = (diag - sw) // 2 - gray_p = cv2.copyMakeBorder( - gray_s, py, diag - sh - py, px, diag - sw - px, cv2.BORDER_REPLICATE, + gray_p, mask_p, center, diag = self._prepare_padded_template( + self.template_gray, mask_src, variant.scale, ) - mask_p = cv2.copyMakeBorder( - mask_s, py, diag - sh - py, px, diag - sw - px, - cv2.BORDER_CONSTANT, value=0, - ) - center = (diag / 2.0, diag / 2.0) M = cv2.getRotationMatrix2D(center, angle_deg, 1.0) gray_r = cv2.warpAffine(gray_p, M, (diag, diag), flags=cv2.INTER_LINEAR, @@ -1125,8 +1086,8 @@ class LineShapeMatcher: ix = int(round(cx)); iy = int(round(cy)) hits = 0 for i in range(n_feat): - xs = ix + int(fx[i] - center[0]) - ys = iy + int(fy[i] - center[1]) + xs = ix + int(round(fx[i] - center[0])) + ys = iy + int(round(fy[i] - center[1])) if 0 <= xs < W and 0 <= ys < H: bit = spread_dtype(1 << int(fb[i])) if spread0[ys, xs] & bit: @@ -1140,26 +1101,13 @@ class LineShapeMatcher: """Soft-margin gradient similarity (Halcon Metric='use_polarity').""" if self.template_gray is None: return 0.0 - h, w = self.template_gray.shape - scale = variant.scale - sw = max(16, int(round(w * scale))) - sh = max(16, int(round(h * scale))) - gray_s = cv2.resize(self.template_gray, (sw, sh), interpolation=cv2.INTER_LINEAR) mask_src = ( self._train_mask if self._train_mask is not None else np.full_like(self.template_gray, 255) ) - mask_s = cv2.resize(mask_src, (sw, sh), interpolation=cv2.INTER_NEAREST) - diag = int(np.ceil(np.hypot(sh, sw))) + 6 - py = (diag - sh) // 2; px = (diag - sw) // 2 - gray_p = cv2.copyMakeBorder( - gray_s, py, diag - sh - py, px, diag - sw - px, cv2.BORDER_REPLICATE, + gray_p, mask_p, center, diag = self._prepare_padded_template( + self.template_gray, mask_src, variant.scale, ) - mask_p = cv2.copyMakeBorder( - mask_s, py, diag - sh - py, px, diag - sw - px, - cv2.BORDER_CONSTANT, value=0, - ) - center = (diag / 2.0, diag / 2.0) M = cv2.getRotationMatrix2D(center, angle_deg, 1.0) gray_r = cv2.warpAffine(gray_p, M, (diag, diag), flags=cv2.INTER_LINEAR, @@ -1179,8 +1127,8 @@ class LineShapeMatcher: ix = int(round(cx)); iy = int(round(cy)) sims = []; weights = [] for i in range(len(fx)): - xs = ix + int(fx[i] - center[0]) - ys = iy + int(fy[i] - center[1]) + xs = ix + int(round(fx[i] - center[0])) + ys = iy + int(round(fy[i] - center[1])) if not (0 <= xs < W and 0 <= ys < H): continue tx = float(gx_t[int(fy[i]), int(fx[i])]) @@ -1201,35 +1149,34 @@ class LineShapeMatcher: def _subpixel_refine_lm( self, scene_gray: np.ndarray, variant: _Variant, cx: float, cy: float, angle_deg: float, - n_iters: int = 2, - ) -> tuple[float, float]: - """Sub-pixel refinement iterativo via gradient-field least-squares. + n_iters: int = 4, + scene_grad: tuple[np.ndarray, np.ndarray] | None = None, + ) -> tuple[float, float, float]: + """Refinement least-squares congiunto di posizione E angolo. - Halcon-equivalent SubPixel='least_squares_high'. Precisione attesa - 0.05 px (vs 0.5 px del fit quadratic 2D). + Halcon-equivalent SubPixel='least_squares_high'. Per ogni feature + template cerca il picco sub-pixel del gradiente scena lungo la + normale dell'edge (snap ±2 px, fit parabolico su 5 campioni), poi + risolve ai minimi quadrati pesati il sistema 3x3 in (dx, dy, dθ): + + n_i · (d + dθ·u_i) = t_i, u_i = (r_y,i, -r_x,i) + + dove r_i = offset feature dal centro, n_i = normale edge template, + t_i = offset del picco lungo n_i, u_i = derivata della rotazione + nella convenzione cv2.getRotationMatrix2D (R = [[c,s],[-s,c]]). + Tra le iterazioni offset e normali vengono ruotati analiticamente + (no re-warp del template). Precisione attesa <0.1 px / <0.1°. + + scene_grad: (gx, gy) Sobel della scena precomputati (evita un + Sobel full-frame per ogni match). Ritorna (cx, cy, angle_deg). """ - if self.template_gray is None: - return cx, cy - h, w = self.template_gray.shape - scale = variant.scale - sw = max(16, int(round(w * scale))) - sh = max(16, int(round(h * scale))) - gray_s = cv2.resize(self.template_gray, (sw, sh), interpolation=cv2.INTER_LINEAR) - mask_src = ( - self._train_mask if self._train_mask is not None - else np.full_like(self.template_gray, 255) + t, train_mask = self._get_view_template(getattr(variant, "view_idx", 0)) + if t is None: + return cx, cy, angle_deg + mask_src = train_mask if train_mask is not None else np.full_like(t, 255) + gray_p, mask_p, center, diag = self._prepare_padded_template( + t, mask_src, variant.scale, ) - mask_s = cv2.resize(mask_src, (sw, sh), interpolation=cv2.INTER_NEAREST) - diag = int(np.ceil(np.hypot(sh, sw))) + 6 - py = (diag - sh) // 2; px = (diag - sw) // 2 - gray_p = cv2.copyMakeBorder( - gray_s, py, diag - sh - py, px, diag - sw - px, cv2.BORDER_REPLICATE, - ) - mask_p = cv2.copyMakeBorder( - mask_s, py, diag - sh - py, px, diag - sw - px, - cv2.BORDER_CONSTANT, value=0, - ) - center = (diag / 2.0, diag / 2.0) M = cv2.getRotationMatrix2D(center, angle_deg, 1.0) gray_r = cv2.warpAffine(gray_p, M, (diag, diag), flags=cv2.INTER_LINEAR, @@ -1241,51 +1188,98 @@ class LineShapeMatcher: mag_t = cv2.magnitude(gx_t, gy_t) _, bins_t = self._gradient(gray_r) fx, fy, _ = self._extract_features(mag_t, bins_t, mask_r) - if len(fx) < 4: - return cx, cy - n = len(fx) - ddx_t = (fx - center[0]).astype(np.float32) - ddy_t = (fy - center[1]).astype(np.float32) - gx_tf = np.array([gx_t[int(fy[i]), int(fx[i])] for i in range(n)], dtype=np.float32) - gy_tf = np.array([gy_t[int(fy[i]), int(fx[i])] for i in range(n)], dtype=np.float32) - mag_tf = np.hypot(gx_tf, gy_tf) + 1e-6 - nx_t = gx_tf / mag_tf - ny_t = gy_tf / mag_tf - gx_s = cv2.Sobel(scene_gray, cv2.CV_32F, 1, 0, ksize=3) - gy_s = cv2.Sobel(scene_gray, cv2.CV_32F, 0, 1, ksize=3) + if len(fx) < 8: + return cx, cy, angle_deg + rx = (fx - center[0]).astype(np.float64) + ry = (fy - center[1]).astype(np.float64) + gxf = gx_t[fy, fx].astype(np.float64) + gyf = gy_t[fy, fx].astype(np.float64) + nm = np.hypot(gxf, gyf) + 1e-9 + nx = gxf / nm + ny = gyf / nm + + if scene_grad is not None: + gx_s, gy_s = scene_grad + else: + gx_s = cv2.Sobel(scene_gray, cv2.CV_32F, 1, 0, ksize=3) + gy_s = cv2.Sobel(scene_gray, cv2.CV_32F, 0, 1, ksize=3) H, W = scene_gray.shape - cur_cx, cur_cy = float(cx), float(cy) - for _ in range(n_iters): - xs = cur_cx + ddx_t - ys = cur_cy + ddy_t - xs_c = np.clip(xs, 0, W - 1.001) - ys_c = np.clip(ys, 0, H - 1.001) + + def _bilin(g: np.ndarray, xs: np.ndarray, ys: np.ndarray) -> np.ndarray: + xs_c = np.clip(xs, 0.0, W - 1.001) + ys_c = np.clip(ys, 0.0, H - 1.001) x0 = xs_c.astype(np.int32); y0 = ys_c.astype(np.int32) ax = xs_c - x0; ay = ys_c - y0 - def _bilin(g): - v00 = g[y0, x0]; v10 = g[y0, x0 + 1] - v01 = g[y0 + 1, x0]; v11 = g[y0 + 1, x0 + 1] - return ((1 - ax) * (1 - ay) * v00 - + ax * (1 - ay) * v10 - + (1 - ax) * ay * v01 - + ax * ay * v11) - sx_v = _bilin(gx_s) - sy_v = _bilin(gy_s) - mag_s = np.hypot(sx_v, sy_v) + 1e-6 - nx_s = sx_v / mag_s - ny_s = sy_v / mag_s - w = np.minimum(mag_s, 255.0).astype(np.float32) - err_x = (nx_s - nx_t) * w - err_y = (ny_s - ny_t) * w - step_x = -float(err_x.sum()) / (w.sum() + 1e-6) - step_y = -float(err_y.sum()) / (w.sum() + 1e-6) - step_x = max(-1.0, min(1.0, step_x)) - step_y = max(-1.0, min(1.0, step_y)) - cur_cx += step_x - cur_cy += step_y - if abs(step_x) < 0.02 and abs(step_y) < 0.02: + return ((1 - ax) * (1 - ay) * g[y0, x0] + + ax * (1 - ay) * g[y0, x0 + 1] + + (1 - ax) * ay * g[y0 + 1, x0] + + ax * ay * g[y0 + 1, x0 + 1]) + + t_offsets = np.array([-2.0, -1.0, 0.0, 1.0, 2.0]) + n_feat = len(rx) + idx = np.arange(n_feat) + cur_cx, cur_cy, cur_ang = float(cx), float(cy), float(angle_deg) + for _ in range(n_iters): + px = cur_cx + rx + py = cur_cy + ry + # |grad| scena campionato a 5 offset lungo la normale di ogni + # feature; il picco sub-pixel lungo la normale e' la distanza + # firmata t_i dall'edge scena piu' vicino. + mags = np.empty((5, n_feat)) + sxs = np.empty((5, n_feat)) + sys_ = np.empty((5, n_feat)) + for k, t_off in enumerate(t_offsets): + sx_v = _bilin(gx_s, px + t_off * nx, py + t_off * ny) + sy_v = _bilin(gy_s, px + t_off * nx, py + t_off * ny) + sxs[k] = sx_v; sys_[k] = sy_v + mags[k] = np.hypot(sx_v, sy_v) + k_best = np.argmax(mags, axis=0) + m_pk = mags[k_best, idx] + t_i = t_offsets[k_best] + # Fit parabolico sui picchi interni (k in 1..3) + interior = (k_best >= 1) & (k_best <= 3) + if interior.any(): + ki = k_best[interior]; ii = idx[interior] + m_m = mags[ki - 1, ii]; m_0 = mags[ki, ii]; m_p = mags[ki + 1, ii] + denom = (m_m - 2.0 * m_0 + m_p) + off = np.where(np.abs(denom) > 1e-9, + 0.5 * (m_m - m_p) / (denom - 1e-12), 0.0) + t_i = t_i.astype(np.float64) + t_i[interior] += np.clip(off, -0.5, 0.5) + # Peso: |grad| al picco * allineamento direzione (mod π se no + # polarity). Feature senza edge (sotto weak_grad) escluse; + # picco sul bordo finestra = snap inaffidabile → dimezzato. + sx_pk = sxs[k_best, idx]; sy_pk = sys_[k_best, idx] + cos_al = (nx * sx_pk + ny * sy_pk) / (m_pk + 1e-9) + align = np.maximum(0.0, cos_al) if self.use_polarity else np.abs(cos_al) + wgt = np.minimum(m_pk, 255.0) * align * align + wgt[m_pk < self.weak_grad] = 0.0 + wgt[~interior] *= 0.5 + if float(wgt.sum()) < 1e-6: break - return cur_cx, cur_cy + # LSQ pesato 3x3: A_i = [n_x, n_y, n_x·r_y - n_y·r_x] + a3 = nx * ry - ny * rx + A = np.stack([nx, ny, a3], axis=1) + Aw = A * wgt[:, None] + AtA = Aw.T @ A + Atb = Aw.T @ t_i.astype(np.float64) + try: + sol = np.linalg.solve(AtA + 1e-6 * np.eye(3), Atb) + except np.linalg.LinAlgError: + break + ddx = float(np.clip(sol[0], -1.5, 1.5)) + ddy = float(np.clip(sol[1], -1.5, 1.5)) + dth = float(np.clip(sol[2], -math.radians(1.5), math.radians(1.5))) + cur_cx += ddx + cur_cy += ddy + cur_ang += math.degrees(dth) + # Ruota offset e normali di dθ (convenzione R = [[c,s],[-s,c]]) + c = math.cos(dth); s = math.sin(dth) + rx, ry = c * rx + s * ry, -s * rx + c * ry + nx, ny = c * nx + s * ny, -s * nx + c * ny + if abs(ddx) < 0.01 and abs(ddy) < 0.01 and abs(dth) < 1.7e-4: + break + return cur_cx, cur_cy, cur_ang def _verify_ncc( self, scene_gray: np.ndarray, cx: float, cy: float, @@ -1370,15 +1364,21 @@ class LineShapeMatcher: coarse_stride: int = 1, scale_penalty: float = 0.0, search_roi: tuple[int, int, int, int] | None = None, - pyramid_propagate: bool = False, # off di default: meno duplicati - propagate_topk: int = 4, - refine_pose_joint: bool = False, + # ON di default: full-res valutato solo in finestre locali attorno + # ai picchi top-level (costo ∝ candidati, non varianti × W × H). + # I duplicati che avevano fatto disattivare questa modalita' sono + # gestiti dalla NMS IoU poligonale post-refine. + pyramid_propagate: bool = True, + propagate_topk: int = 8, + refine_pose_joint: bool = False, # deprecato: alias di refine_angle greediness: float = 0.0, batch_top: bool = False, nms_iou_threshold: float = 0.3, min_recall: float = 0.0, use_soft_score: bool = False, - subpixel_lm: bool = False, + # ON di default: least-squares finale (posizione + angolo) sui + # gradienti scena, precisione attesa <0.1 px / <0.1°. + subpixel_lm: bool = True, debug: bool = False, profile: bool = False, ) -> list[Match]: @@ -1462,7 +1462,7 @@ class LineShapeMatcher: cached = self._scene_cache_get(cache_key) if cache_key else None if cached is not None: grays, spread_top, bit_active_top, density_top, spread0, \ - bit_active_full, density_full, top = cached + bit_active_full, density_full, top, spread_fine = cached else: grays = [gray0] for _ in range(self.pyramid_levels - 1): @@ -1478,6 +1478,7 @@ class LineShapeMatcher: spread0 = None bit_active_full = None density_full = None + spread_fine = None _checkpoint("spread_top") if nms_radius is None: nms_radius = max(8, min(self.template_size) // 2) @@ -1488,6 +1489,18 @@ class LineShapeMatcher: # coarse_angle_factor (skip 1 ogni 2): con step fine non e' utile. # Risultato osservato: precisione "veloce" 10° dava risultati # migliori di "preciso" 2° proprio perche evitava il pruning. + # Il path windowed (pyramid_propagate) assume che il picco + # top-level localizzi la posizione entro il margine finestra. + # Su template ALLUNGATI (es. lama 40x280, o ROI parziale lungo un + # asse) lo score top-level ha un plateau lungo l'asse e il picco + # puo' essere lontano decine di px dal centro vero → le finestre + # tagliano fuori la posa giusta e il match muore in verify NCC. + # In quel caso si usa il full-scan esatto (costo maggiore ma + # nessuna perdita di recall). + if pyramid_propagate and self.template_size != (0, 0): + tw_t, th_t = self.template_size + if max(tw_t, th_t) / max(1, min(tw_t, th_t)) > 2.0: + pyramid_propagate = False eff_step = self._effective_angle_step() top_factor = self.top_score_factor cf_eff = max(1, coarse_angle_factor) @@ -1598,16 +1611,25 @@ class LineShapeMatcher: return vi, -1.0 best = float(score.max()) if pyramid_propagate and best > 0: - flat = score.ravel() - k = min(propagate_topk, flat.size) - idx = np.argpartition(-flat, k - 1)[:k] + # Picchi = MASSIMI LOCALI sopra soglia, non top-K pixel: + # su template allungati lo score top-level ha plateau + # estesi e i top-K pixel si concentrano tutti sulle 2-3 + # istanze piu' forti, perdendo per sempre le altre. + # Soglia permissiva (0.5x): un picco scartato qui = + # istanza persa, un picco in piu' = solo una finestra + # extra di costo marginale (dedup via mark). + thr = top_thresh * 0.5 + dil = cv2.dilate(score, np.ones((5, 5), np.uint8)) + ys_l, xs_l = np.nonzero((score >= dil) & (score >= thr)) peaks: list[tuple[int, int, float]] = [] - for i in idx: - s = float(flat[i]) - if s < top_thresh * 0.7: - continue - yt, xt = int(i // score.shape[1]), int(i % score.shape[1]) - peaks.append((xt, yt, s)) + if len(ys_l): + vals = score[ys_l, xs_l] + k = min(max(propagate_topk, 2 * max_matches), len(vals)) + sel = np.argpartition(-vals, k - 1)[:k] + peaks = [ + (int(xs_l[i]), int(ys_l[i]), float(vals[i])) + for i in sel + ] peaks_by_vi[vi] = peaks return vi, best @@ -1664,6 +1686,13 @@ class LineShapeMatcher: expanded.add(vi_n) # Usa lo score del coarse come stima per il sort successivo score_by_vi[vi_n] = max(score_by_vi.get(vi_n, 0.0), s_top) + # Propaga i picchi top-level del coarse anche ai vicini: + # l'oggetto e' nella stessa posizione (angolo ±step), quindi + # anche i vicini possono usare il path windowed invece del + # full-scan dell'intera scena (che dominava il costo full-res). + if (pyramid_propagate and vi_n != vi_c + and peaks_by_vi.get(vi_c)): + peaks_by_vi.setdefault(vi_n, []).extend(peaks_by_vi[vi_c]) kept_variants: list[tuple[int, float]] = [ (vi, score_by_vi[vi]) for vi in expanded ] @@ -1690,54 +1719,63 @@ class LineShapeMatcher: if (spread0 & (spread0.dtype.type(1) << b)).any()) ) density_full = _jit_popcount(spread0) + # Bitmap fine (raggio 1) per il refine: sulla bitmap dilatata + # lo score satura e il refine angolare/posizionale non vede + # alcun gradiente (vedi _refine_angle). + spread_fine = self._spread_bitmap(gray0, radius=1) # Salva cache scena complete if cache_key is not None: self._scene_cache_put(cache_key, ( grays, spread_top, bit_active_top, density_top, - spread0, bit_active_full, density_full, top, + spread0, bit_active_full, density_full, top, spread_fine, )) for sc in unique_scales: bg_cache_full[sc] = _bg_for_scale(density_full, sc, 1) # Margine in full-res attorno ad ogni peak top: copre incertezza - # downsampling (sf_top px) + spread_radius + slack per NMS. - propagate_margin = sf_top + self.spread_radius + max(8, nms_radius // 2) + # downsampling (2·sf_top px) + plateau radius del subpixel (10) + + # slack. NON serve includere nms_radius: la NMS lavora sui candidati + # estratti, non richiede score validi oltre il plateau del picco. + propagate_margin = 2 * sf_top + max(10, self.spread_radius) + 6 H_full, W_full = spread0.shape def _full_score(vi: int) -> tuple[int, np.ndarray]: var = self.variants[vi] lvl0 = var.levels[0] - if not pyramid_propagate or vi not in peaks_by_vi or not peaks_by_vi[vi]: + peaks = peaks_by_vi.get(vi) if pyramid_propagate else None + margin = propagate_margin + if not peaks: # Path legacy: scansiona intera scena return vi, _jit_score_bitmap_rescored( spread0, lvl0.dx, lvl0.dy, lvl0.bin, bit_active_full, bg_cache_full[var.scale], ) - # Path piramide propagata: valuta solo crop locali attorno - # alle posizioni dei picchi top-level (riproiettati a full-res). + # Path piramide propagata: valuta solo finestre locali attorno + # ai picchi top-level (riproiettati a full-res). Il kernel + # windowed campiona lo spread dell'INTERA scena: chiamare il + # kernel su un crop trattava le feature fuori-crop come miss + # (template raggio > finestra → score ~0 ovunque, 0 match). score_full = np.zeros((H_full, W_full), dtype=np.float32) mark = np.zeros((H_full, W_full), dtype=bool) bg = bg_cache_full[var.scale] - for xt, yt, _s in peaks_by_vi[vi]: + for xt, yt, _s in peaks: cx0 = xt * sf_top cy0 = yt * sf_top - x_lo = max(0, cx0 - propagate_margin) - x_hi = min(W_full, cx0 + propagate_margin + 1) - y_lo = max(0, cy0 - propagate_margin) - y_hi = min(H_full, cy0 + propagate_margin + 1) + x_lo = max(0, cx0 - margin) + x_hi = min(W_full, cx0 + margin + 1) + y_lo = max(0, cy0 - margin) + y_hi = min(H_full, cy0 + margin + 1) if x_hi <= x_lo or y_hi <= y_lo: continue if mark[y_lo:y_hi, x_lo:x_hi].all(): continue - # Crop spread + bg, valuta kernel sul crop - spread_crop = np.ascontiguousarray(spread0[y_lo:y_hi, x_lo:x_hi]) - bg_crop = np.ascontiguousarray(bg[y_lo:y_hi, x_lo:x_hi]) - score_crop = _jit_score_bitmap_rescored( - spread_crop, lvl0.dx, lvl0.dy, lvl0.bin, - bit_active_full, bg_crop, + score_win = _jit_score_bitmap_rescored_window( + spread0, lvl0.dx, lvl0.dy, lvl0.bin, + bit_active_full, bg, + y_lo, x_lo, y_hi - y_lo, x_hi - x_lo, ) score_full[y_lo:y_hi, x_lo:x_hi] = np.maximum( - score_full[y_lo:y_hi, x_lo:x_hi], score_crop, + score_full[y_lo:y_hi, x_lo:x_hi], score_win, ) mark[y_lo:y_hi, x_lo:x_hi] = True return vi, score_full @@ -1811,6 +1849,14 @@ class LineShapeMatcher: # Subpixel + refine + verify solo sui candidati pre-NMS (max pre_cap) kept: list[Match] = [] tw, th = self.template_size + # Sobel scena precomputato una volta per il refine LM (prima era + # un Sobel full-frame per OGNI match). + scene_grad = None + if subpixel_lm and self.template_gray is not None and preliminary_int: + scene_grad = ( + cv2.Sobel(gray0, cv2.CV_32F, 1, 0, ksize=3), + cv2.Sobel(gray0, cv2.CV_32F, 0, 1, ksize=3), + ) for score, xi, yi, vi in preliminary_int: if subpixel and vi in score_maps: cx_f, cy_f = self._subpixel_peak( @@ -1821,12 +1867,10 @@ class LineShapeMatcher: var = self.variants[vi] ang_f = var.angle_deg score_f = score - if refine_pose_joint and self.template_gray is not None: - ang_f, score_f, cx_f, cy_f = self._refine_pose_joint( - spread0, self.template_gray, cx_f, cy_f, - var.angle_deg, var.scale, mask_full, - ) - elif refine_angle and self.template_gray is not None: + # refine_pose_joint (Nelder-Mead) rimosso: valutava lo score a + # posizioni intere su bitmap satura (funzione a gradini piatta, + # il simplex terminava subito). Ora e' alias del refine standard. + if (refine_angle or refine_pose_joint) and self.template_gray is not None: ang_f, score_f, cx_f, cy_f = self._refine_angle( spread0, bit_active_full, self.template_gray, cx_f, cy_f, var.angle_deg, var.scale, mask_full, @@ -1835,14 +1879,15 @@ class LineShapeMatcher: # del bin angolare della variante grezza. search_radius=self._effective_angle_step(), original_score=score, + spread_fine=spread_fine, ) - # Halcon SubPixel='least_squares_high': refinement iterativo - # gradient-field per precisione 0.05 px (vs 0.5 quadratic 2D). + # Halcon SubPixel='least_squares_high': least-squares finale + # (posizione + angolo) sui gradienti scena, <0.1 px / <0.1°. if subpixel_lm and self.template_gray is not None: - cx_lm, cy_lm = self._subpixel_refine_lm( - gray0, var, cx_f, cy_f, ang_f, + cx_lm, cy_lm, ang_lm = self._subpixel_refine_lm( + gray0, var, cx_f, cy_f, ang_f, scene_grad=scene_grad, ) - cx_f, cy_f = float(cx_lm), float(cy_lm) + cx_f, cy_f, ang_f = float(cx_lm), float(cy_lm), float(ang_lm) # NCC verify (Halcon-style): se ncc_skip_above < 1.0 salta # il calcolo per shape-score gia alti. Default 1.01 = NCC sempre, # piu sicuro contro falsi positivi (lo shape-score satura facile). From 9458173ad09ff2ccf400d398970d9dc3ddd3b989 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 12 Jun 2026 11:54:40 +0000 Subject: [PATCH 2/3] fix: robustezza web/gui/legacy (lock matcher, LRU cache, clamp ROI, overlay) - server: lock globale matcher (race nel threadpool FastAPI), LRU su _IMG_CACHE e _RECIPE_MATCHERS (leak), clamp ROI in tutti gli endpoint (400/422 invece di crash 500, check train senza varianti), filtro_fp=off disabilita davvero il verify NCC, fallback FILTRO_FP_MAP = medio, verify_threshold ricetta allineato a 0.4, _draw_matches su crop locale (era warp+Sobel full-frame per ogni match), spread_radius default 5->4 - gui: centro overlay edge (W-1)/2 -> W/2 (coerenza col train), spread_radius 5->4 - matcher legacy: _angle_list include estremo, cap candidati top-level, save/load persiste template_gray - auto_tune: ref centrato fuori dal loop angoli - test_suite: check imread con errore chiaro Co-Authored-By: Claude Fable 5 --- benchmarks/test_suite.py | 5 + pm2d/auto_tune.py | 3 +- pm2d/gui.py | 10 +- pm2d/matcher.py | 28 ++- pm2d/web/server.py | 381 +++++++++++++++++++++++++-------------- 5 files changed, 282 insertions(+), 145 deletions(-) diff --git a/benchmarks/test_suite.py b/benchmarks/test_suite.py index 4518892..414f13d 100644 --- a/benchmarks/test_suite.py +++ b/benchmarks/test_suite.py @@ -36,6 +36,11 @@ CONFIGS = [ def bench(case_name: str, img_path: str, roi_box: tuple, roi_kind: str, cfg_name: str, cfg: dict) -> dict: scene = cv2.imread(str(TEST_DIR / img_path)) + if scene is None: + # cv2.imread ritorna None silenzioso: senza check il crash arriva + # dopo, sullo slice, con un errore criptico. + raise FileNotFoundError( + f"Immagine di test non trovata o non leggibile: {TEST_DIR / img_path}") y0, y1, x0, x1 = roi_box roi = scene[y0:y1, x0:x1].copy() m = LineShapeMatcher( diff --git a/pm2d/auto_tune.py b/pm2d/auto_tune.py index f62f96e..93545a0 100644 --- a/pm2d/auto_tune.py +++ b/pm2d/auto_tune.py @@ -61,6 +61,8 @@ def detect_rotational_symmetry( center = (w / 2.0, h / 2.0) ref = mag + # ref è costante nel loop sugli angoli: centra una volta sola + rm = ref - ref.mean() correlations: list[tuple[float, float]] = [] for ang in np.arange(step_deg, 360.0, step_deg): @@ -68,7 +70,6 @@ def detect_rotational_symmetry( rot = cv2.warpAffine( mag, M, (w, h), borderValue=0.0, ) - rm = ref - ref.mean() rs = rot - rot.mean() denom = np.sqrt((rm * rm).sum() * (rs * rs).sum()) + 1e-9 c = float((rm * rs).sum() / denom) diff --git a/pm2d/gui.py b/pm2d/gui.py index 316f787..2e32e57 100644 --- a/pm2d/gui.py +++ b/pm2d/gui.py @@ -196,8 +196,10 @@ def _warp_template_edges_to_scene( edge = cv2.Canny(template_gray, canny_low, canny_high) # Matrice affine: scala + rotazione attorno al centro template, poi traslazione Ht, Wt = h, w - cx_t = (Wt - 1) / 2.0 - cy_t = (Ht - 1) / 2.0 + # Centro coerente con la convenzione train (center = w / 2.0, no -1): + # (Wt-1)/2 introduceva uno shift di 0.5px per template di lato pari. + cx_t = Wt / 2.0 + cy_t = Ht / 2.0 M = cv2.getRotationMatrix2D((cx_t, cy_t), angle_deg, scale) # Traslazione per portare centro template a (cx, cy) della scena M[0, 2] += cx - cx_t @@ -492,7 +494,9 @@ def run( num_features: int = 96, weak_grad: float = 30.0, strong_grad: float = 60.0, - spread_radius: int = 5, + # 4 allineato col default del matcher: raggio 5 peggiora la precisione + # di rotazione (spread troppo largo appiattisce il picco angolare). + spread_radius: int = 4, pyramid_levels: int = 3, min_score: float = 0.55, max_matches: int = 25, diff --git a/pm2d/matcher.py b/pm2d/matcher.py index d90e620..14b5e90 100644 --- a/pm2d/matcher.py +++ b/pm2d/matcher.py @@ -91,8 +91,16 @@ class EdgeShapeMatcher: a0, a1 = self.angle_range_deg if self.angle_step_deg <= 0 or a0 >= a1: return [float(a0)] - n = int(np.floor((a1 - a0) / self.angle_step_deg)) - return [float(a0 + i * self.angle_step_deg) for i in range(n)] + # n+1 valori per includere l'estremo superiore del range: con il + # solo floor un range [0, 90] step 5 si fermava a 85° (off-by-one). + n = int(np.floor((a1 - a0) / self.angle_step_deg)) + 1 + angles = [float(a0 + i * self.angle_step_deg) for i in range(n)] + if a1 - a0 >= 360.0: + # Range che copre il giro completo: a0+360° è la stessa pose di + # a0, escludi il duplicato (variante inutile in train/find). + eps = 1e-6 + angles = [a for a in angles if a < a0 + 360.0 - eps] + return angles def train(self, template_bgr: np.ndarray) -> int: """Genera varianti per tutte le combinazioni (angolo, scala).""" @@ -222,6 +230,14 @@ class EdgeShapeMatcher: for y, x in zip(ys, xs): candidates.append((float(res[y, x]), int(x), int(y), ti)) + # Cap candidati top-level: senza limite np.where con soglia bassa + # può generare migliaia di candidati, ognuno con un matchTemplate + # full-res nel refinement. Tieni solo i migliori per score. + max_candidates = max(1, max_matches * 10) + if len(candidates) > max_candidates: + candidates.sort(key=lambda c: -c[0]) + candidates = candidates[:max_candidates] + # Refinement a risoluzione piena: per ogni candidato top, finestra locale refined: list[tuple[float, int, int, int]] = [] margin = sf + 4 @@ -294,6 +310,10 @@ class EdgeShapeMatcher: ) arrays = {f"edge_{i}": t.edge for i, t in enumerate(self.templates)} arrays.update({f"mask_{i}": t.mask for i, t in enumerate(self.templates)}) + # Persisti anche il grayscale originale: senza, l'overlay edge + # spariva dopo load() (template_gray restava None). + if self.template_gray is not None: + arrays["template_gray"] = self.template_gray np.savez_compressed(path, params=params, meta=meta, **arrays) @classmethod @@ -312,6 +332,10 @@ class EdgeShapeMatcher: top_score_factor=float(p[12]) if len(p) > 12 else 0.6, ) m.template_size = (int(p[8]), int(p[9])) + # Retrocompatibilità: modelli salvati prima non hanno template_gray + # (resta None: overlay edge non disponibile ma find() funziona). + if "template_gray" in z.files: + m.template_gray = z["template_gray"] meta = z["meta"] for i in range(len(meta)): m.templates.append( diff --git a/pm2d/web/server.py b/pm2d/web/server.py index f1d58fb..7a77872 100644 --- a/pm2d/web/server.py +++ b/pm2d/web/server.py @@ -12,6 +12,7 @@ from __future__ import annotations import hashlib import os import tempfile +import threading import time import uuid from collections import OrderedDict @@ -64,14 +65,23 @@ STATIC_DIR.mkdir(exist_ok=True) CACHE_DIR = Path(tempfile.gettempdir()) / "pm2d_cache" CACHE_DIR.mkdir(exist_ok=True) -# Cache in-memory (soft, ricaricata da disco se mancante) -_IMG_CACHE: dict[str, np.ndarray] = {} +# Cache in-memory (soft, ricaricata da disco se mancante). +# LRU con capacità limitata: senza eviction le immagini si accumulavano +# senza limite (leak di memoria su server long-running). +_IMG_CACHE: OrderedDict[str, np.ndarray] = OrderedDict() +_IMG_CACHE_SIZE = 64 # Cache matcher addestrati: (roi_hash, params_hash) -> LineShapeMatcher # LRU con capacità limitata _MATCHER_CACHE: OrderedDict = OrderedDict() _MATCHER_CACHE_SIZE = 8 +# Lock globale matcher: gli endpoint girano nel threadpool FastAPI ma i +# matcher condivisi (_MATCHER_CACHE, _RECIPE_MATCHERS) mutano stato interno +# durante train()/find(). Serializzare il matching è la soluzione semplice +# e corretta (un lock per-ricetta sarebbe over-engineering). +_MATCHER_LOCK = threading.Lock() + def _matcher_cache_key(roi: np.ndarray, tech: dict) -> str: h = hashlib.md5() @@ -102,23 +112,32 @@ def _cache_put_matcher(key: str, matcher) -> None: _MATCHER_CACHE.popitem(last=False) +def _img_cache_put(key: str, value: np.ndarray) -> None: + """Inserisce in _IMG_CACHE con eviction LRU (cap _IMG_CACHE_SIZE).""" + _IMG_CACHE[key] = value + _IMG_CACHE.move_to_end(key) + while len(_IMG_CACHE) > _IMG_CACHE_SIZE: + _IMG_CACHE.popitem(last=False) + + def _store_image(img: np.ndarray) -> str: iid = uuid.uuid4().hex[:12] cv2.imwrite(str(CACHE_DIR / f"{iid}.png"), img) - _IMG_CACHE[iid] = img + _img_cache_put(iid, img) return iid def _load_image(iid: str) -> np.ndarray | None: cached = _IMG_CACHE.get(iid) if cached is not None: + _IMG_CACHE.move_to_end(iid) # LRU touch return cached p = CACHE_DIR / f"{iid}.png" if not p.exists(): return None img = cv2.imread(str(p)) if img is not None: - _IMG_CACHE[iid] = img + _img_cache_put(iid, img) return img app = FastAPI(title="PM2D Webapp", version="1.0.0") @@ -131,6 +150,39 @@ def _encode_png(img: np.ndarray) -> bytes: return buf.tobytes() +def _clamp_roi(x: int, y: int, w: int, h: int, + img_w: int, img_h: int) -> tuple[int, int, int, int]: + """Clampa la ROI dentro i limiti immagine. + + Una ROI fuori immagine causava slice vuote → crash 500 negli endpoint + che non clampavano. Solleva 400 se la ROI risultante è degenere + (lato < 16 px: sotto questa soglia il train non estrae abbastanza + edge feature e produce 0 varianti → find() esplode con 500). + """ + x = max(0, min(int(x), img_w - 1)) + y = max(0, min(int(y), img_h - 1)) + w = min(int(w), img_w - x) + h = min(int(h), img_h - y) + if w < 16 or h < 16: + raise HTTPException( + 400, f"ROI fuori immagine o degenere: [{x}, {y}, {w}, {h}] " + f"su immagine {img_w}x{img_h} (lato minimo 16 px)") + return x, y, w, h + + +def _check_trained(m: "LineShapeMatcher", n_variants: int) -> None: + """Solleva 422 se il train non ha prodotto varianti. + + Succede con ROI senza contrasto (sfondo uniforme) o troppo piccola: + senza questo check il find() successivo esplode con RuntimeError → 500. + """ + if n_variants <= 0 or not m.variants: + raise HTTPException( + 422, "La ROI non contiene abbastanza edge feature per il " + "training (zona troppo uniforme o piccola): scegliere " + "una regione con contorni netti") + + def _draw_matches(scene: np.ndarray, matches: list[Match], template_gray: np.ndarray | None, matcher: "LineShapeMatcher | None" = None) -> np.ndarray: @@ -172,36 +224,53 @@ def _draw_matches(scene: np.ndarray, matches: list[Match], # `center = (diag / 2.0, diag / 2.0)` (no -1). Usare (tw-1)/2 # introduceva uno shift di 0.5px per template di lato pari. cx_t = tw / 2.0; cy_t = th / 2.0 - M = cv2.getRotationMatrix2D((cx_t, cy_t), m.angle_deg, m.scale) - M[0, 2] += m.cx - cx_t - M[1, 2] += m.cy - cy_t - warped_gray = cv2.warpAffine( - t, M, (W_scene, H_scene), - flags=cv2.INTER_LINEAR, borderValue=0) - # Maschera: train_mask se disponibile, altrimenti rettangolo pieno - mask_src = (matcher._train_mask if matcher._train_mask is not None - else np.full((th, tw), 255, dtype=np.uint8)) - warped_mask = cv2.warpAffine( - mask_src, M, (W_scene, H_scene), - flags=cv2.INTER_NEAREST, borderValue=0) - # Erode minimo (3x3) per togliere SOLO artefatti border-padding - # (~1px di bordo nero da warpAffine borderValue=0). Erode piu' - # grande spostava visualmente l'edge verso l'interno e creava - # apparente "traslazione fissa" rispetto al bordo del pezzo. - kernel_er = np.ones((3, 3), np.uint8) - warped_mask = cv2.erode(warped_mask, kernel_er) - mag, _ = matcher._gradient(warped_gray) - if matcher.weak_grad < matcher.strong_grad: - edge_mask = matcher._hysteresis_mask(mag) - else: - edge_mask = mag >= matcher.strong_grad - edge_mask = edge_mask & (warped_mask > 0) - if edge_mask.any(): - edge_overlay = np.zeros_like(out) - # Ciano (cambiato da verde): non collide col verde dell'asse - # Y dell'UCS che altrimenti scompariva nell'overlay edge. - edge_overlay[edge_mask] = (255, 200, 0) # ciano (BGR) - out = cv2.addWeighted(out, 1.0, edge_overlay, 0.6, 0) + # Lavora su un CROP locale della scena di lato = diagonale del + # template ruotato+scalato (+margine), come _verify_ncc: warp + # + Sobel sull'INTERA scena per ogni match erano O(W·H) cadauno + # (costosissimo su scene grandi con molti match). + diag = int(np.ceil(np.hypot(tw, th) * m.scale)) + 8 + x0 = int(round(m.cx)) - diag // 2 + y0 = int(round(m.cy)) - diag // 2 + gx0 = max(0, x0); gy0 = max(0, y0) + gx1 = min(W_scene, x0 + diag); gy1 = min(H_scene, y0 + diag) + cw, ch_ = gx1 - gx0, gy1 - gy0 + if cw >= 3 and ch_ >= 3: + M = cv2.getRotationMatrix2D((cx_t, cy_t), m.angle_deg, m.scale) + # Porta il centro template a (m.cx - gx0, m.cy - gy0) del crop + M[0, 2] += (m.cx - gx0) - cx_t + M[1, 2] += (m.cy - gy0) - cy_t + warped_gray = cv2.warpAffine( + t, M, (cw, ch_), + flags=cv2.INTER_LINEAR, borderValue=0) + # Maschera: train_mask se disponibile, altrimenti rettangolo pieno + mask_src = (matcher._train_mask if matcher._train_mask is not None + else np.full((th, tw), 255, dtype=np.uint8)) + warped_mask = cv2.warpAffine( + mask_src, M, (cw, ch_), + flags=cv2.INTER_NEAREST, borderValue=0) + # Erode minimo (3x3) per togliere SOLO artefatti border-padding + # (~1px di bordo nero da warpAffine borderValue=0). Erode piu' + # grande spostava visualmente l'edge verso l'interno e creava + # apparente "traslazione fissa" rispetto al bordo del pezzo. + kernel_er = np.ones((3, 3), np.uint8) + warped_mask = cv2.erode(warped_mask, kernel_er) + mag, _ = matcher._gradient(warped_gray) + if matcher.weak_grad < matcher.strong_grad: + edge_mask = matcher._hysteresis_mask(mag) + else: + edge_mask = mag >= matcher.strong_grad + edge_mask = edge_mask & (warped_mask > 0) + if edge_mask.any(): + # Edge ritraslati nel sistema scena: blend solo sul crop + # (addWeighted lascia invariati i pixel con overlay nullo, + # quindi l'output visivo è identico al full-frame). + sub = out[gy0:gy1, gx0:gx1] + edge_overlay = np.zeros_like(sub) + # Ciano (cambiato da verde): non collide col verde dell'asse + # Y dell'UCS che altrimenti scompariva nell'overlay edge. + edge_overlay[edge_mask] = (255, 200, 0) # ciano (BGR) + out[gy0:gy1, gx0:gx1] = cv2.addWeighted( + sub, 1.0, edge_overlay, 0.6, 0) L = max(20, int(L_base * m.scale)) # X axis = rotazione di (1, 0) con cv2 matrix → (cos, -sin) x_end = (int(cx + L * ca), int(cy - L * sa)) @@ -246,7 +315,9 @@ class MatchParams(BaseModel): num_features: int = 96 weak_grad: float = 30.0 strong_grad: float = 60.0 - spread_radius: int = 5 + # 4 allineato col default del matcher: raggio 5 peggiora la precisione + # di rotazione (spread troppo largo appiattisce il picco angolare). + spread_radius: int = 4 pyramid_levels: int = 3 verify_threshold: float = 0.4 @@ -407,7 +478,13 @@ def _simple_to_technical( "min_score": p.min_score, "max_matches": p.max_matches, "nms_radius": 0, - "verify_threshold": FILTRO_FP_MAP.get(p.filtro_fp, 0.35), + # Fallback = livello "medio" della mappa (no valore hardcoded + # che divergerebbe se la mappa cambia). + "verify_threshold": FILTRO_FP_MAP.get(p.filtro_fp, FILTRO_FP_MAP["medio"]), + # "off" deve disabilitare DAVVERO il verify NCC: passare solo + # verify_threshold=0.0 lascerebbe attivo il calcolo NCC (che può + # comunque scartare match con score negativo / patch uniformi). + "verify_ncc": p.filtro_fp != "off", "scale_penalty": p.penalita_scala, } @@ -541,9 +618,7 @@ def match(p: MatchParams): if model is None or scene is None: raise HTTPException(404, "Immagini non trovate") x, y, w, h = p.roi - x = max(0, x); y = max(0, y) - w = max(1, min(w, model.shape[1] - x)) - h = max(1, min(h, model.shape[0] - y)) + x, y, w, h = _clamp_roi(x, y, w, h, model.shape[1], model.shape[0]) roi_img = model[y:y + h, x:x + w] tech_for_cache = { @@ -557,33 +632,38 @@ def match(p: MatchParams): "pyramid_levels": p.pyramid_levels, } key = _matcher_cache_key(roi_img, tech_for_cache) - m = _cache_get_matcher(key) - if m is None: - m = LineShapeMatcher( - num_features=p.num_features, - weak_grad=p.weak_grad, strong_grad=p.strong_grad, - angle_range_deg=(p.angle_min, p.angle_max), - angle_step_deg=p.angle_step, - scale_range=(p.scale_min, p.scale_max), - scale_step=p.scale_step, - spread_radius=p.spread_radius, - pyramid_levels=p.pyramid_levels, + # Lock globale: matcher condivisi tra thread del pool FastAPI + with _MATCHER_LOCK: + m = _cache_get_matcher(key) + if m is None: + m = LineShapeMatcher( + num_features=p.num_features, + weak_grad=p.weak_grad, strong_grad=p.strong_grad, + angle_range_deg=(p.angle_min, p.angle_max), + angle_step_deg=p.angle_step, + scale_range=(p.scale_min, p.scale_max), + scale_step=p.scale_step, + spread_radius=p.spread_radius, + pyramid_levels=p.pyramid_levels, + ) + t0 = time.time(); n = m.train(roi_img); t_train = time.time() - t0 + _check_trained(m, n) + _cache_put_matcher(key, m) + else: + n = len(m.variants); t_train = 0.0 + nms = p.nms_radius if p.nms_radius > 0 else None + t0 = time.time() + matches = m.find( + scene, min_score=p.min_score, max_matches=p.max_matches, + nms_radius=nms, verify_threshold=p.verify_threshold, + # Soglia 0 = filtro FP disattivato: skippa proprio il calcolo NCC + verify_ncc=p.verify_threshold > 0.0, ) - t0 = time.time(); n = m.train(roi_img); t_train = time.time() - t0 - _cache_put_matcher(key, m) - else: - n = len(m.variants); t_train = 0.0 - nms = p.nms_radius if p.nms_radius > 0 else None - t0 = time.time() - matches = m.find( - scene, min_score=p.min_score, max_matches=p.max_matches, - nms_radius=nms, verify_threshold=p.verify_threshold, - ) - t_find = time.time() - t0 + t_find = time.time() - t0 - # Render annotated image - tg = cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY) - annotated = _draw_matches(scene, matches, tg, matcher=m) + # Render annotated image + tg = cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY) + annotated = _draw_matches(scene, matches, tg, matcher=m) ann_id = _store_image(annotated) return MatchResp( @@ -610,9 +690,7 @@ def match_simple(p: SimpleMatchParams): if model is None or scene is None: raise HTTPException(404, "Immagini non trovate") x, y, w, h = p.roi - x = max(0, x); y = max(0, y) - w = max(1, min(w, model.shape[1] - x)) - h = max(1, min(h, model.shape[0] - y)) + x, y, w, h = _clamp_roi(x, y, w, h, model.shape[1], model.shape[0]) roi_img = model[y:y + h, x:x + w] tech = _simple_to_technical(p, roi_img) @@ -621,47 +699,52 @@ def match_simple(p: SimpleMatchParams): # Halcon-mode init params: incidono sul training, includere in cache key halcon_init_key = f"|pol={p.use_polarity}|gpu={p.use_gpu}" key = key + halcon_init_key - m = _cache_get_matcher(key) - if m is None: - m = LineShapeMatcher( - num_features=tech["num_features"], - weak_grad=tech["weak_grad"], strong_grad=tech["strong_grad"], - angle_range_deg=(tech["angle_min"], tech["angle_max"]), - angle_step_deg=tech["angle_step"], - scale_range=(tech["scale_min"], tech["scale_max"]), - scale_step=tech["scale_step"], - spread_radius=tech["spread_radius"], - min_feature_spacing=tech.get("min_feature_spacing", 3), - pyramid_levels=tech["pyramid_levels"], - use_polarity=p.use_polarity, - use_gpu=p.use_gpu, + # Lock globale: matcher condivisi tra thread del pool FastAPI + with _MATCHER_LOCK: + m = _cache_get_matcher(key) + if m is None: + m = LineShapeMatcher( + num_features=tech["num_features"], + weak_grad=tech["weak_grad"], strong_grad=tech["strong_grad"], + angle_range_deg=(tech["angle_min"], tech["angle_max"]), + angle_step_deg=tech["angle_step"], + scale_range=(tech["scale_min"], tech["scale_max"]), + scale_step=tech["scale_step"], + spread_radius=tech["spread_radius"], + min_feature_spacing=tech.get("min_feature_spacing", 3), + pyramid_levels=tech["pyramid_levels"], + use_polarity=p.use_polarity, + use_gpu=p.use_gpu, + ) + t0 = time.time(); n = m.train(roi_img); t_train = time.time() - t0 + _check_trained(m, n) + _cache_put_matcher(key, m) + else: + n = len(m.variants); t_train = 0.0 + nms = tech["nms_radius"] if tech["nms_radius"] > 0 else None + search_roi_t = tuple(p.search_roi) if p.search_roi else None + t0 = time.time() + matches = m.find( + scene, min_score=tech["min_score"], max_matches=tech["max_matches"], + nms_radius=nms, verify_threshold=tech["verify_threshold"], + # filtro_fp="off" → verify NCC davvero disabilitato + verify_ncc=tech.get("verify_ncc", True), + scale_penalty=tech.get("scale_penalty", 0.0), + # Halcon-mode flags + min_recall=p.min_recall, + use_soft_score=p.use_soft_score, + subpixel_lm=p.subpixel_lm, + nms_iou_threshold=p.nms_iou_threshold, + coarse_stride=p.coarse_stride, + pyramid_propagate=p.pyramid_propagate, + greediness=p.greediness, + refine_pose_joint=p.refine_pose_joint, + search_roi=search_roi_t, ) - t0 = time.time(); n = m.train(roi_img); t_train = time.time() - t0 - _cache_put_matcher(key, m) - else: - n = len(m.variants); t_train = 0.0 - nms = tech["nms_radius"] if tech["nms_radius"] > 0 else None - search_roi_t = tuple(p.search_roi) if p.search_roi else None - t0 = time.time() - matches = m.find( - scene, min_score=tech["min_score"], max_matches=tech["max_matches"], - nms_radius=nms, verify_threshold=tech["verify_threshold"], - scale_penalty=tech.get("scale_penalty", 0.0), - # Halcon-mode flags - min_recall=p.min_recall, - use_soft_score=p.use_soft_score, - subpixel_lm=p.subpixel_lm, - nms_iou_threshold=p.nms_iou_threshold, - coarse_stride=p.coarse_stride, - pyramid_propagate=p.pyramid_propagate, - greediness=p.greediness, - refine_pose_joint=p.refine_pose_joint, - search_roi=search_roi_t, - ) - t_find = time.time() - t0 + t_find = time.time() - t0 - tg = cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY) - annotated = _draw_matches(scene, matches, tg, matcher=m) + tg = cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY) + annotated = _draw_matches(scene, matches, tg, matcher=m) ann_id = _store_image(annotated) return MatchResp( @@ -681,6 +764,7 @@ def tune(p: TuneParams): if model is None: raise HTTPException(404, "Immagine non trovata") x, y, w, h = p.roi + x, y, w, h = _clamp_roi(x, y, w, h, model.shape[1], model.shape[0]) roi_img = model[y:y + h, x:x + w] t = auto_tune(roi_img) # Esponi parametri tecnici + meta diagnostica (_self_score, _validation, @@ -814,6 +898,7 @@ def save_recipe(p: SaveRecipeParams): if model is None: raise HTTPException(404, "Modello non trovato") x, y, w, h = p.roi + x, y, w, h = _clamp_roi(x, y, w, h, model.shape[1], model.shape[0]) roi_img = model[y:y + h, x:x + w] sp = SimpleMatchParams( model_id=p.model_id, scene_id=p.scene_id or p.model_id, roi=p.roi, @@ -838,7 +923,10 @@ def save_recipe(p: SaveRecipeParams): use_polarity=p.use_polarity, use_gpu=p.use_gpu, ) - m.train(roi_img) + # Lock globale: serializza il training pesante col matching in corso + with _MATCHER_LOCK: + n_var = m.train(roi_img) + _check_trained(m, n_var) safe_name = "".join(c for c in p.name if c.isalnum() or c in "._-") if not safe_name: raise HTTPException(400, "Nome ricetta non valido") @@ -864,6 +952,14 @@ _RECIPE_MATCHERS: OrderedDict = OrderedDict() _RECIPE_MATCHERS_SIZE = 4 +def _recipe_matchers_put(name: str, matcher: LineShapeMatcher) -> None: + """Inserisce in _RECIPE_MATCHERS con eviction LRU (cap _RECIPE_MATCHERS_SIZE).""" + _RECIPE_MATCHERS[name] = matcher + _RECIPE_MATCHERS.move_to_end(name) + while len(_RECIPE_MATCHERS) > _RECIPE_MATCHERS_SIZE: + _RECIPE_MATCHERS.popitem(last=False) + + @app.post("/recipes/{name}/load") def load_recipe(name: str): """Carica ricetta .npz e popola cache matcher in memoria. @@ -878,10 +974,8 @@ def load_recipe(name: str): if not path.is_file(): raise HTTPException(404, f"Ricetta non trovata: {safe_name}") m = LineShapeMatcher.load_model(str(path)) - _RECIPE_MATCHERS[safe_name] = m - _RECIPE_MATCHERS.move_to_end(safe_name) - while len(_RECIPE_MATCHERS) > _RECIPE_MATCHERS_SIZE: - _RECIPE_MATCHERS.popitem(last=False) + with _MATCHER_LOCK: + _recipe_matchers_put(safe_name, m) return { "name": safe_name, "n_variants": len(m.variants), @@ -905,7 +999,9 @@ class RecipeMatchParams(BaseModel): greediness: float = 0.0 refine_pose_joint: bool = False search_roi: list[int] | None = None - verify_threshold: float = 0.5 + # Allineato a MatchParams.verify_threshold (0.4): valori divergenti + # davano risultati diversi tra /match e /match_recipe a parità di scena. + verify_threshold: float = 0.4 scale_penalty: float = 0.0 @@ -913,37 +1009,44 @@ class RecipeMatchParams(BaseModel): def match_recipe(p: RecipeMatchParams): """Match con ricetta pre-trained: zero training, solo find.""" safe_name = p.recipe if p.recipe.endswith(".npz") else f"{p.recipe}.npz" - m = _RECIPE_MATCHERS.get(safe_name) - if m is None: - # Auto-load on demand - path = RECIPES_DIR / safe_name - if not path.is_file(): - raise HTTPException(404, f"Ricetta non trovata: {safe_name}") - m = LineShapeMatcher.load_model(str(path)) - _RECIPE_MATCHERS[safe_name] = m scene = _load_image(p.scene_id) if scene is None: raise HTTPException(404, "Scena non trovata") search_roi_t = tuple(p.search_roi) if p.search_roi else None - t0 = time.time() - matches = m.find( - scene, - min_score=p.min_score, max_matches=p.max_matches, - verify_threshold=p.verify_threshold, - scale_penalty=p.scale_penalty, - min_recall=p.min_recall, - use_soft_score=p.use_soft_score, - subpixel_lm=p.subpixel_lm, - nms_iou_threshold=p.nms_iou_threshold, - coarse_stride=p.coarse_stride, - pyramid_propagate=p.pyramid_propagate, - greediness=p.greediness, - refine_pose_joint=p.refine_pose_joint, - search_roi=search_roi_t, - ) - t_find = time.time() - t0 - tg = m.template_gray if m.template_gray is not None else np.zeros((1, 1), np.uint8) - annotated = _draw_matches(scene, matches, tg, matcher=m) + # Lock globale: matcher condivisi tra thread del pool FastAPI + with _MATCHER_LOCK: + m = _RECIPE_MATCHERS.get(safe_name) + if m is not None: + _RECIPE_MATCHERS.move_to_end(safe_name) # LRU touch + else: + # Auto-load on demand: stessa eviction LRU di load_recipe + # (senza cap la cache cresceva senza limite) + path = RECIPES_DIR / safe_name + if not path.is_file(): + raise HTTPException(404, f"Ricetta non trovata: {safe_name}") + m = LineShapeMatcher.load_model(str(path)) + _recipe_matchers_put(safe_name, m) + t0 = time.time() + matches = m.find( + scene, + min_score=p.min_score, max_matches=p.max_matches, + verify_threshold=p.verify_threshold, + # Soglia 0 = filtro FP disattivato: skippa proprio il calcolo NCC + verify_ncc=p.verify_threshold > 0.0, + scale_penalty=p.scale_penalty, + min_recall=p.min_recall, + use_soft_score=p.use_soft_score, + subpixel_lm=p.subpixel_lm, + nms_iou_threshold=p.nms_iou_threshold, + coarse_stride=p.coarse_stride, + pyramid_propagate=p.pyramid_propagate, + greediness=p.greediness, + refine_pose_joint=p.refine_pose_joint, + search_roi=search_roi_t, + ) + t_find = time.time() - t0 + tg = m.template_gray if m.template_gray is not None else np.zeros((1, 1), np.uint8) + annotated = _draw_matches(scene, matches, tg, matcher=m) ann_id = _store_image(annotated) return MatchResp( matches=[MatchResult( From 4356a47d06c5922c8b5ac6ca4e7fa92d73c65f54 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 12 Jun 2026 11:54:40 +0000 Subject: [PATCH 3/3] docs: roadmap Fase 2 (precisione misurata, valutazione C++ vs algoritmico) Co-Authored-By: Claude Fable 5 --- ROADMAP.md | 43 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index da66484..d73031e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,36 @@ Lista ragionata di miglioramenti futuri. Priorità = impatto / effort, non urgenza temporale. +## Fase 2 COMPLETATA (precisione rotazione + robustezza + perf) + +Root cause della rotazione imprecisa: lo score satura a 1.0 sulla spread +bitmap dilatata (raggio 4-5) → il refine non vedeva gradiente né in angolo +né in posizione, e `cv2.minMaxLoc` sul plateau saturo spostava il centro +sull'angolo della finestra (errore sistematico 3·√2 ≈ 4.24 px). + +| Fix | Dettaglio | +|---|---| +| Refine su bitmap fine | `_refine_angle` ottimizza su spread raggio 1 (`spread_fine`, cached); score finale ricalcolato su spread coarse per mantenere semantica soglie | +| Picco sub-pixel nel refine | centroide plateau / fit quadratico al posto di minMaxLoc (bias top-left) | +| LM least-squares pos+angolo | `_subpixel_refine_lm` riscritto: snap edge ±2px lungo normale + LSQ 3x3 (dx, dy, dθ), ON di default | +| Round feature offsets | troncamento `astype(int32)` → `np.round` (bias ~0.25 px) | +| Centro rotazione coerente | `_prepare_padded_template`: rotazione attorno al centro reale del template nel padding (bias ≤0.5 px dipendente dall'angolo) | +| `_angle_list` include estremo | range parziali ±tol ora testano anche +tol | +| `_refine_pose_joint` rimosso | Nelder-Mead su funzione a gradini satura: terminava subito; param ora alias di refine_angle | +| pyramid_propagate di default | kernel windowed (feature campionano l'intera scena: prima il crop troncava le feature → score 0); picchi = massimi locali (non top-K pixel); disattivato automaticamente per template elongati (>2:1) dove il picco top-level non localizza | +| Piramide 3 livelli default | con clamp automatico sulla dimensione template (min 12 px al top) | +| Cache scena: hash completo | prima hashava solo i primi 64KB → collisioni tra scene con stessa banda superiore → risultati della scena sbagliata | +| Web server | lock matcher (race con threadpool FastAPI), LRU `_IMG_CACHE`, clamp ROI ovunque (400/422 invece di 500), `filtro_fp=off` disabilita davvero NCC, `_draw_matches` su crop locale | +| GUI/legacy | centro overlay `(W-1)/2`→`W/2`, spread_radius default 5→4, EdgeShapeMatcher: angle list endpoint + cap candidati + save template_gray | + +Misure (GT sintetica 7 pose, scena 900x700, VPS 2 core): +- Errore angolare mediano: **2.3° → 0.05°** (step 5°); a step 2° era 4.4° → **0.03°** +- Errore posizione mediano: **4.24 px → 0.04 px** +- find GT scene: 4.7s → 1.7s; scena reale 646x482: 1.14s → 0.81s +- Benchmark suite 16 scenari: 96.5s → 84.2s, match count ≥ baseline + (eccezioni: dado_full -1 = match borderline su parte diversa; + lama_part_preciso 25→18 con baseline al cap max_matches) + ## Fase 1 COMPLETATA (branch `speedFase1`) | ID | Voce | Status | Note | @@ -84,9 +114,14 @@ Benchmark suite 16 scenari (4 immagini × full/part × fast/preciso): ## Target performance produzione Obiettivi da documento tecnico Vision Suite (Fase Beta): -- [ ] **Precisione posizionale mediana**: <0.5 px → **raggiunto con subpixel (attualmente ~0.1-0.3 px atteso)** -- [ ] **Precisione angolare mediana**: <1.0° → **raggiunto con refinement (~0.5°)** -- [ ] **Latency mediana**: <50 ms su 1920×1080 → **attuale ~1.7s su 830×822 (serve GPU o ulteriore CPU)** +- [x] **Precisione posizionale mediana**: <0.5 px → **0.04 px misurato su GT sintetica (Fase 2)** +- [x] **Precisione angolare mediana**: <1.0° → **0.05° misurato su GT sintetica (Fase 2)** +- [ ] **Latency mediana**: <50 ms su 1920×1080 → **~0.8s su 646×482 con 2 core; da misurare su hardware produzione** - [ ] **F1 score dataset sintetico**: >0.95 → **da misurare con dataset sintetico** -Prossimo blocker per target: **latency**. Via più promettente: GPU (CuPy) o coarse-to-fine angolare. +Prossimo blocker per target: **latency**. Nota: i kernel hot sono gia' +Numba JIT (≈ velocita' C, prange parallelo): un port C++ dei kernel vale +solo il margine SIMD esplicito (~2-4x con AVX2 su AND+popcount byte-wise). +Prima di scriverlo conviene esaurire le vie algoritmiche rimaste: +riduzione varianti al top-level (auto angle step per livello, stile +Halcon), greediness di default, e GPU (CuPy/OpenCL) per scene 1080p.