diff --git a/pm2d/web/server.py b/pm2d/web/server.py index d82666d..7f04462 100644 --- a/pm2d/web/server.py +++ b/pm2d/web/server.py @@ -78,6 +78,7 @@ def _matcher_cache_key(roi: np.ndarray, tech: dict) -> str: h.update(roi.tobytes()) # Solo parametri che influenzano il training relevant = ("num_features", "weak_grad", "strong_grad", + "min_feature_spacing", "angle_min", "angle_max", "angle_step", "scale_min", "scale_max", "scale_step", "spread_radius", "pyramid_levels") @@ -133,124 +134,47 @@ def _encode_png(img: np.ndarray) -> bytes: def _draw_matches(scene: np.ndarray, matches: list[Match], template_gray: np.ndarray | None, matcher: "LineShapeMatcher | None" = None) -> np.ndarray: - """Disegna match annotati sulla scena. + """Disegna SOLO UCS (richiesta utente) per ogni match trovato. - Se matcher e' passato, usa la stessa pipeline di edge filtering - (hysteresis weak/strong_grad) e selezione feature usata in training, - cosi' l'overlay nel match riflette ESATTAMENTE quello che l'utente - ha visto nel preview "Anteprima edge". Inoltre disegna UCS - (asse X rosso, Y verde) sul centro pose del match. - - Senza matcher: fallback Canny (legacy). + UCS = sistema di coordinate (X rosso, Y verde) posizionato sul + baricentro feature del modello, ruotato secondo l'angolo del match. + Niente edge, niente cerchietti feature, niente bbox: i match sulla + scena reale devono essere puliti, gli edge filtrati si vedono solo + nell'anteprima modello. """ out = scene.copy() - H, W = scene.shape[:2] - palette = [ - (0, 255, 0), (0, 200, 255), (255, 100, 100), (255, 200, 0), - (200, 0, 255), (100, 255, 200), (255, 0, 0), (0, 255, 255), - ] - bin_colors = [ - (255, 0, 0), (255, 128, 0), (255, 255, 0), (0, 255, 0), - (0, 255, 255), (0, 128, 255), (0, 0, 255), (255, 0, 255), - (255, 100, 100), (255, 180, 100), (255, 230, 100), (180, 255, 100), - (100, 255, 200), (100, 180, 255), (180, 100, 255), (255, 100, 200), - ] + # Baricentro UCS in coord template (calcolato una volta dal matcher + # se disponibile): mean delle feature di una variante a 0°. Questo e' + # lo stesso baricentro mostrato nell'anteprima modello. + bary_dx = bary_dy = 0.0 + if matcher is not None and matcher.variants: + # Trova variante con angle_deg piu vicino a 0 + v0 = min(matcher.variants, key=lambda v: abs(v.angle_deg)) + if len(v0.levels[0].dx) > 0: + bary_dx = float(np.mean(v0.levels[0].dx)) + bary_dy = float(np.mean(v0.levels[0].dy)) + for i, m in enumerate(matches): - color = palette[i % len(palette)] - # Posizione UCS: baricentro feature warpate (default = cx, cy se non disponibile). - # Mantiene coerenza con anteprima modello che mostra UCS sul baricentro. - ucs_x, ucs_y = float(m.cx), float(m.cy) - if template_gray is not None and matcher is not None: - t = template_gray - th, tw = t.shape - cx_t = (tw - 1) / 2.0; cy_t = (th - 1) / 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 - # Background edge filtrati: warpa template + hysteresis - warped_gray = cv2.warpAffine( - t, M, (W, H), flags=cv2.INTER_LINEAR, borderValue=0) - 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 - if edge_mask.any(): - bg_overlay = np.zeros_like(out) - dark = tuple(int(c * 0.35) for c in color) - bg_overlay[edge_mask] = dark - out = cv2.addWeighted(out, 1.0, bg_overlay, 0.7, 0) - # Feature reali del matcher: usa quelle pre-computate della - # variante che ha generato il match. Stesse identiche feature - # mostrate nell'anteprima modello (ruotate/scalate alla pose). - vi = getattr(m, "variant_idx", -1) - fx_scene = fy_scene = fb_arr = None - if 0 <= vi < len(matcher.variants): - lvl0 = matcher.variants[vi].levels[0] - # dx/dy sono offsets relativi al CENTRO del template warpato - # nelle coordinate del kernel template (gia' pre-ruotate - # all'angolo della variante grezza). Per coerenza con la - # pose finale m.angle_deg (post-refine), ri-rotazione del - # delta angolare (m.angle_deg - var.angle_deg). - var = matcher.variants[vi] - dang = np.deg2rad(m.angle_deg - var.angle_deg) - ca, sa = np.cos(dang), np.sin(dang) - dxr = lvl0.dx * ca + lvl0.dy * sa - dyr = -lvl0.dx * sa + lvl0.dy * ca - fx_scene = m.cx + dxr - fy_scene = m.cy + dyr - fb_arr = lvl0.bin - else: - # Fallback: estrai feature dal warpato (perde precisione) - _, bins_w = matcher._gradient(warped_gray) - fx, fy, fb = matcher._extract_features(mag, bins_w, None) - fx_scene = fx.astype(np.float32) - fy_scene = fy.astype(np.float32) - fb_arr = fb - # Disegna feature - for k in range(len(fx_scene)): - px = int(round(float(fx_scene[k]))) - py = int(round(float(fy_scene[k]))) - if 0 <= px < W and 0 <= py < H: - bcol = bin_colors[int(fb_arr[k]) % len(bin_colors)] - cv2.circle(out, (px, py), 2, bcol, -1, cv2.LINE_AA) - # UCS sul baricentro feature (in scene coords) - if len(fx_scene) > 0: - ucs_x = float(np.mean(fx_scene)) - ucs_y = float(np.mean(fy_scene)) - elif template_gray is not None: - # Senza matcher: legacy Canny - t = template_gray - th, tw = t.shape - cx_t = (tw - 1) / 2.0; cy_t = (th - 1) / 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 - edge = cv2.Canny(t, 50, 150) - warped = cv2.warpAffine(edge, M, (W, H), - flags=cv2.INTER_NEAREST, borderValue=0) - mask = warped > 0 - if mask.any(): - overlay = np.zeros_like(out) - overlay[mask] = color - out[mask] = (0.3 * out[mask] + 0.7 * overlay[mask]).astype(np.uint8) - # bbox poly e linea-marker rimossi (richiesta utente "togli la ROI"): - # UCS + edge filtrati gia' identificano pose e orientamento. - cx, cy = int(round(ucs_x)), int(round(ucs_y)) - # UCS sul centro pose match (richiesta utente: come nell'anteprima - # modello). Asse X rosso destra, Y verde basso (image y-down). - # Lunghezza derivata dalla diagonale bbox per scala-invariante. - L = int(np.linalg.norm(m.bbox_poly[1] - m.bbox_poly[0])) // 2 - if L < 10: - L = 30 # fallback se bbox degenere + # Proietta baricentro template alla pose del match + # (delta-rotation rispetto alla variante a 0) ax = np.deg2rad(m.angle_deg) - # X axis ruotato (rosso) + ca, sa = np.cos(ax), np.sin(ax) + bx_scene = m.cx + (bary_dx * ca + bary_dy * sa) * m.scale + by_scene = m.cy + (-bary_dx * sa + bary_dy * ca) * m.scale + cx, cy = int(round(bx_scene)), int(round(by_scene)) + # Lunghezza assi: 30% del lato bbox per essere visibile e scalato + if m.bbox_poly is not None and len(m.bbox_poly) >= 2: + L = int(np.linalg.norm(m.bbox_poly[1] - m.bbox_poly[0]) * 0.4) + else: + L = 40 + L = max(20, L) + # X axis (rosso) ruotato secondo angle del match x_end = (int(cx + L * np.cos(ax)), int(cy - L * np.sin(ax))) cv2.arrowedLine(out, (cx, cy), x_end, (0, 0, 255), 2, cv2.LINE_AA, tipLength=0.2) cv2.putText(out, "X", (x_end[0] + 4, x_end[1] + 5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1, cv2.LINE_AA) - # Y axis perpendicolare (verde, +90° in image coords = giu' visivo) + # Y axis (verde) perpendicolare; +90° in image coords = giu' visivo y_end = (int(cx + L * np.cos(ax + np.pi / 2)), int(cy - L * np.sin(ax + np.pi / 2))) cv2.arrowedLine(out, (cx, cy), y_end, @@ -260,9 +184,6 @@ def _draw_matches(scene: np.ndarray, matches: list[Match], # Origine UCS: cerchio bianco con bordo nero cv2.circle(out, (cx, cy), 4, (0, 0, 0), -1, cv2.LINE_AA) cv2.circle(out, (cx, cy), 3, (255, 255, 255), -1, cv2.LINE_AA) - label = f"#{i+1} {m.angle_deg:.0f}d s={m.scale:.2f} {m.score:.2f}" - cv2.putText(out, label, (cx + 12, cy - 12), - cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 2, cv2.LINE_AA) return out @@ -365,6 +286,15 @@ class SimpleMatchParams(BaseModel): penalita_scala: float = 0.0 # 0 = score shape invariante, >0 = penalizza scala != 1 min_score: float = 0.65 max_matches: int = 25 + # --- Override edge da pannello "Anteprima edge" (None = auto_tune) --- + # Quando settati, sovrascrivono i valori derivati da auto_tune e + # vengono usati identici sia nel training del matcher sia nel find. + # Salvati nella ricetta cosi' la stessa pulizia rumore e' replicata + # quando la ricetta viene caricata. + edge_weak_grad: float | None = None + edge_strong_grad: float | None = None + edge_num_features: int | None = None + edge_min_feature_spacing: int | None = None # --- Halcon-mode flags (default off = backward compat) --- # Init-time (richiede ri-train se cambiato) use_polarity: bool = False # F: 16 bin orientation mod 2pi @@ -413,10 +343,24 @@ def _simple_to_technical( smin, smax, sstep = SCALE_PRESETS.get(p.scala, (1.0, 1.0, 0.1)) ang_step = PRECISION_ANGLE_STEP.get(p.precisione, 5.0) + # Override edge dal pannello "Anteprima edge" se utente li ha settati. + # Questi sostituiscono i valori auto_tune nel training del matcher, + # garantendo che la selezione edge identica a quella del preview + # venga usata sia in training sia in find. + weak_g = (p.edge_weak_grad if p.edge_weak_grad is not None + else tune["weak_grad"]) + strong_g = (p.edge_strong_grad if p.edge_strong_grad is not None + else tune["strong_grad"]) + n_feat = (p.edge_num_features if p.edge_num_features is not None + else nf) + min_sp = (p.edge_min_feature_spacing if p.edge_min_feature_spacing is not None + else 3) + return { - "num_features": nf, - "weak_grad": tune["weak_grad"], - "strong_grad": tune["strong_grad"], + "num_features": n_feat, + "weak_grad": weak_g, + "strong_grad": strong_g, + "min_feature_spacing": min_sp, "spread_radius": spread, "pyramid_levels": pyr, "angle_min": 0.0, @@ -652,6 +596,7 @@ def match_simple(p: SimpleMatchParams): 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, @@ -721,6 +666,11 @@ class SaveRecipeParams(BaseModel): precisione: str = "normale" use_polarity: bool = False use_gpu: bool = False + # Override edge dal pannello "Anteprima edge" (None = auto_tune) + edge_weak_grad: float | None = None + edge_strong_grad: float | None = None + edge_num_features: int | None = None + edge_min_feature_spacing: int | None = None name: str # nome file ricetta (no path) @@ -838,6 +788,10 @@ def save_recipe(p: SaveRecipeParams): tipo=p.tipo, simmetria=p.simmetria, scala=p.scala, precisione=p.precisione, use_polarity=p.use_polarity, use_gpu=p.use_gpu, + edge_weak_grad=p.edge_weak_grad, + edge_strong_grad=p.edge_strong_grad, + edge_num_features=p.edge_num_features, + edge_min_feature_spacing=p.edge_min_feature_spacing, ) tech = _simple_to_technical(sp, roi_img) m = LineShapeMatcher( diff --git a/pm2d/web/static/app.js b/pm2d/web/static/app.js index 02e00fa..540989e 100644 --- a/pm2d/web/static/app.js +++ b/pm2d/web/static/app.js @@ -53,10 +53,34 @@ function readUserParams() { document.getElementById("p-penalita-scala").value), min_score: parseFloat(document.getElementById("p-min-score").value), max_matches: parseInt(document.getElementById("p-max-matches").value, 10), + ...readEdgeOverrides(), ...readHalconFlags(), }; } +function readEdgeOverrides() { + // Override edge dal pannello "Anteprima edge". Settati = utente li ha + // toccati (anche se uguali al default attuale). Vengono propagati a + // _simple_to_technical e usati identici sia in training sia in find. + // Inoltre salvati nella ricetta cosi' si replicano al load. + const _v = (id, parser) => { + const el = document.getElementById(id); + if (!el) return null; + const v = parser(el.value); + return Number.isFinite(v) ? v : null; + }; + // Sempre passa i valori correnti degli slider: e' la richiesta utente + // che i param di pulizia rumore vengano usati anche nel find/ricetta. + const polCb = document.getElementById("hc-use-polarity"); + return { + edge_weak_grad: _v("ep-weak", parseFloat), + edge_strong_grad: _v("ep-strong", parseFloat), + edge_num_features: _v("ep-nf", parseInt), + edge_min_feature_spacing: _v("ep-sp", parseInt), + use_polarity: polCb?.checked || document.getElementById("ep-pol")?.checked, + }; +} + function readHalconFlags() { // Halcon-mode toggle: tutti i flag default-off, esposti via "Modalità Halcon" const $cb = (id) => document.getElementById(id)?.checked ?? false; @@ -716,6 +740,10 @@ async function saveRecipe() { precisione: user.precisione, use_polarity: user.use_polarity, use_gpu: user.use_gpu, + edge_weak_grad: user.edge_weak_grad, + edge_strong_grad: user.edge_strong_grad, + edge_num_features: user.edge_num_features, + edge_min_feature_spacing: user.edge_min_feature_spacing, name: name, }; try {