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) <noreply@anthropic.com>
This commit is contained in:
2026-04-24 10:12:26 +02:00
parent 45e3a29ff0
commit 1954bc6ffd
+26 -2
View File
@@ -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)