diff --git a/pm2d/line_matcher.py b/pm2d/line_matcher.py index ce035c6..99d131e 100644 --- a/pm2d/line_matcher.py +++ b/pm2d/line_matcher.py @@ -740,6 +740,94 @@ class LineShapeMatcher: s2, cx2, cy2 = _score_at_angle(x2) return best + def _compute_soft_score( + self, scene_gray: np.ndarray, variant: _Variant, + cx: float, cy: float, angle_deg: float, + ) -> float: + """Soft-margin gradient similarity (Halcon Metric='use_polarity'). + + Score = mean(max(0, cos(theta_template - theta_scene))) sulle + feature template alla pose, pesato per magnitude scena. Continuo + in [0, 1], piu discriminante della metric a bin (Y di "Halcon + improvements"): match a leggera rotazione = penalita' graduale + invece di on/off bin. + + Polarity: + - use_polarity=True: cos(theta_t - theta_s) considera direzione + completa (mod 2pi) + - use_polarity=False: |cos(theta_t - theta_s)| considera solo + orientazione (mod pi) + """ + 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, + ) + 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, + borderMode=cv2.BORDER_REPLICATE) + mask_r = cv2.warpAffine(mask_p, M, (diag, diag), + flags=cv2.INTER_NEAREST, borderValue=0) + # Gradient template (continuo, non quantizzato) + gx_t = cv2.Sobel(gray_r, cv2.CV_32F, 1, 0, ksize=3) + gy_t = cv2.Sobel(gray_r, cv2.CV_32F, 0, 1, ksize=3) + mag_t = cv2.magnitude(gx_t, gy_t) + # Estrai posizioni feature alla pose + _, bins_t = self._gradient(gray_r) + fx, fy, _ = self._extract_features(mag_t, bins_t, mask_r) + if len(fx) < 4: + return 0.0 + # Gradient scena (continuo) + 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 + 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]) + if not (0 <= xs < W and 0 <= ys < H): + continue + tx = float(gx_t[int(fy[i]), int(fx[i])]) + ty = float(gy_t[int(fy[i]), int(fx[i])]) + sx = float(gx_s[ys, xs]); sy = float(gy_s[ys, xs]) + tm = math.hypot(tx, ty); sm = math.hypot(sx, sy) + if tm < 1e-3 or sm < 1e-3: + continue + # cos(theta_t - theta_s) = (tx*sx + ty*sy) / (tm*sm) + cos_sim = (tx * sx + ty * sy) / (tm * sm) + if not self.use_polarity: + # Mod pi: |cos| considera solo orientazione (no polarity) + cos_sim = abs(cos_sim) + else: + cos_sim = max(0.0, cos_sim) + sims.append(cos_sim) + weights.append(min(sm, 255.0)) + if not sims: + return 0.0 + sims_arr = np.asarray(sims, dtype=np.float32) + w_arr = np.asarray(weights, dtype=np.float32) + return float((sims_arr * w_arr).sum() / (w_arr.sum() + 1e-9)) + def _verify_ncc( self, scene_gray: np.ndarray, cx: float, cy: float, angle_deg: float, scale: float, @@ -828,6 +916,7 @@ class LineShapeMatcher: greediness: float = 0.0, batch_top: bool = False, nms_iou_threshold: float = 0.3, + use_soft_score: bool = False, ) -> list[Match]: """ scale_penalty: se > 0, riduce lo score per match a scala diversa da 1.0: @@ -1189,6 +1278,14 @@ class LineShapeMatcher: if ncc < verify_threshold: continue score_f = (float(score_f) + max(0.0, ncc)) * 0.5 + # Soft-margin gradient similarity: sostituisce o integra lo + # score con metric continua (cos sim gradients) invece di + # bin discreto. Halcon-style: piu robusto a piccole rotazioni. + if use_soft_score: + soft = self._compute_soft_score( + gray0, var, cx_f, cy_f, ang_f, + ) + score_f = (float(score_f) + soft) * 0.5 # Re-check min_score sullo score finale: NCC averaging puo # abbattere lo shape-score sotto la soglia user. Senza questo # check apparivano match con score < min_score (UI confusing).