From 1954bc6ffd78907aaff54803a93c1bb09bd553d5 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 24 Apr 2026 10:12:26 +0200 Subject: [PATCH] fix: allineamento preciso match (skip refine saturo + plateau centroid) Bug: modello == scena non sovrapponeva perfettamente. 1. refine_angle trovava angoli spurious -2.5 deg con score saturo 1.0 perche' parabolic fit su picco saturo estrapola rumore. Fix: skip refine quando original_score >= 0.99 2. Subpixel peak su plateau (spread_radius=5 satura picco su area) sceglieva pixel random via cv2.minMaxLoc. Fix: se >1 pixel a score >= max-0.01 nel raggio 10 usa CENTROIDE del plateau invece del parabolic fit. Test self-match tooth_rim foro piccolo: prima: pos=(355, 111.50) delta=(0, -3.50) ang=-2.5 deg dopo: pos=(355, 115.00) delta=(0, +0.00) ang=+0.0 deg Co-Authored-By: Claude Opus 4.7 (1M context) --- pm2d/line_matcher.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/pm2d/line_matcher.py b/pm2d/line_matcher.py index 5540777..ec5257d 100644 --- a/pm2d/line_matcher.py +++ b/pm2d/line_matcher.py @@ -333,9 +333,26 @@ class LineShapeMatcher: return _jit_score_by_shift(resp, dx, dy, bins, bin_has_data) @staticmethod - def _subpixel_peak(acc: np.ndarray, x: int, y: int) -> tuple[float, float]: - """Fit parabolico 2D attorno al picco per offset subpixel (±0.5 px).""" + def _subpixel_peak( + acc: np.ndarray, x: int, y: int, plateau_radius: int = 10, + ) -> tuple[float, float]: + """Posizione sub-pixel del picco. + + Se c'è un plateau di valori ~massimi (spread_radius satura il peak + su un'area) ritorna il CENTROIDE del plateau. Altrimenti fit + parabolico 2D ±0.5 px. + """ H, W = acc.shape + val = float(acc[y, x]) + # Plateau detection: valori >= val - 0.01 entro raggio limitato + y0 = max(0, y - plateau_radius); y1 = min(H, y + plateau_radius + 1) + x0 = max(0, x - plateau_radius); x1 = min(W, x + plateau_radius + 1) + patch = acc[y0:y1, x0:x1] + plateau = patch >= val - 0.01 + if plateau.sum() > 1: + ys_m, xs_m = np.where(plateau) + return float(x0 + xs_m.mean()), float(y0 + ys_m.mean()) + # Fallback parabolico if x <= 0 or x >= W - 1 or y <= 0 or y >= H - 1: return float(x), float(y) c = acc[y, x] @@ -359,6 +376,7 @@ class LineShapeMatcher: mask_full: np.ndarray, angle_fine_step: float = 0.5, search_radius: float | None = None, + original_score: float | None = None, ) -> tuple[float, float, float, float]: """Ricerca angolare fine (sub-step) attorno al match grezzo. @@ -366,6 +384,11 @@ class LineShapeMatcher: l'angolo con score massimo (parabolic fit sulle 3 score centrali). Ritorna (angle_refined, score, cx_refined, cy_refined). """ + # Se il match grezzo è già quasi perfetto, NON refinare: il parabolic + # fit su picco saturo produce spostamenti spurious di posizione e + # angolo (esempio: modello==scena deve dare ang=0, pos=centro ROI) + if original_score is not None and original_score >= 0.99: + return (angle_deg, original_score, cx, cy) if search_radius is None: search_radius = self.angle_step_deg / 2.0 offsets = np.linspace(-search_radius, search_radius, 5) @@ -652,6 +675,7 @@ class LineShapeMatcher: spread0, bit_active_full, self.template_gray, cx_f, cy_f, var.angle_deg, var.scale, mask_full, search_radius=self.angle_step_deg / 2.0, + original_score=score, ) if verify_ncc: ncc = self._verify_ncc(gray0, cx_f, cy_f, ang_f, var.scale)