"""GUI standalone OpenCV per Pattern Matching 2D. Flusso: 1. Apri immagine modello (file dialog tk) 2. Selezione ROI con cv2.selectROI 3. Apri immagine scena 4. Esegui matching 5. Visualizza risultati (baricentro, angolo, score, bbox) Tutta la logica algoritmica vive in pm2d.matcher.EdgeShapeMatcher. """ from __future__ import annotations import sys from pathlib import Path from tkinter import Tk, filedialog import tkinter as tk from tkinter import ttk import cv2 import numpy as np from pm2d.matcher import EdgeShapeMatcher from pm2d.line_matcher import LineShapeMatcher, Match from pm2d.auto_tune import auto_tune, summarize as tune_summary # Schema campi form parametri: (key, label, type, initial) PARAM_SCHEMA: list[tuple[str, str, type]] = [ ("backend", "Backend (line | edge)", str), ("angle_min", "Angolo min [deg]", float), ("angle_max", "Angolo max [deg]", float), ("angle_step", "Angolo step [deg]", float), ("scale_min", "Scala min", float), ("scale_max", "Scala max", float), ("scale_step", "Scala step", float), ("min_score", "Score minimo [0..1]", float), ("max_matches", "Max match", int), ("nms_radius", "NMS radius [px] (0=auto)", int), ("num_features", "Num feature (line)", int), ("weak_grad", "Weak grad (line)", float), ("strong_grad", "Strong grad (line)", float), ("spread_radius", "Spread radius (line)", int), ("pyramid_levels", "Pyramid levels", int), ] def edit_params(defaults: dict, template_bgr: np.ndarray | None = None) -> dict | None: """Dialog tkinter per modificare i parametri. Se `template_bgr` fornito, mostra bottone "Auto-tune" che analizza il template e pre-popola i campi con valori suggeriti. """ root = tk.Tk() root.title("Parametri Pattern Matching 2D") try: root.attributes("-topmost", True) except Exception: pass result: dict = {} entries: dict[str, tk.Entry] = {} frame = ttk.Frame(root, padding=12) frame.grid(row=0, column=0, sticky="nsew") for i, (key, label, _typ) in enumerate(PARAM_SCHEMA): ttk.Label(frame, text=label).grid(row=i, column=0, sticky="w", padx=4, pady=3) e = ttk.Entry(frame, width=14) e.insert(0, str(defaults.get(key, ""))) e.grid(row=i, column=1, padx=4, pady=3) entries[key] = e hint_var = tk.StringVar(value="") hint_lbl = ttk.Label(frame, textvariable=hint_var, foreground="#0088bb", wraplength=280) hint_lbl.grid(row=len(PARAM_SCHEMA), column=0, columnspan=2, sticky="w", pady=(6, 0)) def apply_tune(): if template_bgr is None: hint_var.set("Auto-tune non disponibile (template mancante)") return tune = auto_tune(template_bgr) for key, _label, _typ in PARAM_SCHEMA: if key in tune: entries[key].delete(0, tk.END) entries[key].insert(0, str(tune[key])) hint_var.set("Auto-tune: " + tune_summary(tune)) state = {"ok": False} def on_ok(): try: for key, _label, typ in PARAM_SCHEMA: val = entries[key].get().strip() if typ is int: result[key] = int(float(val)) elif typ is float: result[key] = float(val) else: result[key] = val state["ok"] = True root.destroy() except ValueError as ex: hint_var.set(f"Errore parametri: {ex}") def on_cancel(): root.destroy() btns = ttk.Frame(frame) btns.grid(row=len(PARAM_SCHEMA) + 1, column=0, columnspan=2, pady=(10, 0)) if template_bgr is not None: ttk.Button(btns, text="Auto-tune", command=apply_tune).pack(side="left", padx=6) ttk.Button(btns, text="Annulla", command=on_cancel).pack(side="left", padx=6) ttk.Button(btns, text="OK", command=on_ok).pack(side="left", padx=6) root.bind("", lambda _e: on_ok()) root.bind("", lambda _e: on_cancel()) root.mainloop() return result if state["ok"] else None WINDOW_MODEL = "Modello (selezionare ROI - INVIO conferma, c annulla)" WINDOW_RESULT = "Risultato matching" def pick_file(title: str, initialdir: str | None = None) -> str | None: """Tk file picker (root nascosto).""" root = Tk() root.withdraw() path = filedialog.askopenfilename( title=title, initialdir=initialdir, filetypes=[ ("Immagini", "*.png *.jpg *.jpeg *.bmp *.tif *.tiff"), ("Tutti i file", "*.*"), ], ) root.destroy() return path or None def load_image(path: str) -> np.ndarray: img = cv2.imread(path, cv2.IMREAD_COLOR) if img is None: raise FileNotFoundError(f"Impossibile leggere immagine: {path}") return img def select_roi(image: np.ndarray) -> np.ndarray | None: """Apre finestra di selezione ROI. Ritorna ROI BGR o None se annullato.""" disp = _fit_for_display(image, max_side=1200) scale = disp.shape[1] / image.shape[1] r = cv2.selectROI(WINDOW_MODEL, disp, showCrosshair=True, fromCenter=False) cv2.destroyWindow(WINDOW_MODEL) x, y, w, h = r if w == 0 or h == 0: return None # Riporta a coordinate immagine originale x0 = int(round(x / scale)) y0 = int(round(y / scale)) w0 = int(round(w / scale)) h0 = int(round(h / scale)) x0 = max(0, x0); y0 = max(0, y0) w0 = max(1, min(w0, image.shape[1] - x0)) h0 = max(1, min(h0, image.shape[0] - y0)) return image[y0:y0 + h0, x0:x0 + w0].copy() def _fit_for_display(image: np.ndarray, max_side: int = 1200) -> np.ndarray: h, w = image.shape[:2] m = max(h, w) if m <= max_side: return image s = max_side / m return cv2.resize(image, (int(w * s), int(h * s)), interpolation=cv2.INTER_AREA) def _warp_template_edges_to_scene( template_gray: np.ndarray, cx: float, cy: float, angle_deg: float, scale: float, scene_shape: tuple[int, int], canny_low: int = 50, canny_high: int = 150, ) -> np.ndarray: """Ritorna mask edge del template ruotato+scalato posizionato in scena.""" h, w = template_gray.shape 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 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 M[1, 2] += cy - cy_t warped = cv2.warpAffine( edge, M, (scene_shape[1], scene_shape[0]), flags=cv2.INTER_NEAREST, borderValue=0, ) return warped def draw_matches( scene: np.ndarray, matches: list[Match], template_gray: np.ndarray | None = None, overlay_alpha: float = 0.7, canny_for_overlay: tuple[int, int] = (50, 150), ) -> np.ndarray: """Disegna bbox orientato + overlay edge template + baricentro + etichetta.""" out = scene.copy() H, W = scene.shape[:2] for i, m in enumerate(matches): color = _color_for(i) # Overlay edge template nella pose del match (se template disponibile) if template_gray is not None: emap = _warp_template_edges_to_scene( template_gray, m.cx, m.cy, m.angle_deg, m.scale, (H, W), canny_low=canny_for_overlay[0], canny_high=canny_for_overlay[1], ) mask = emap > 0 if mask.any(): overlay_color = np.zeros_like(out) overlay_color[mask] = color out[mask] = ( (1 - overlay_alpha) * out[mask] + overlay_alpha * overlay_color[mask] ).astype(np.uint8) # Bbox orientato (poligono) poly = m.bbox_poly.astype(np.int32).reshape(-1, 1, 2) cv2.polylines(out, [poly], isClosed=True, color=color, thickness=2, lineType=cv2.LINE_AA) # Lato top evidenziato per leggere orientamento p0 = tuple(m.bbox_poly[0].astype(int)) p1 = tuple(m.bbox_poly[1].astype(int)) cv2.line(out, p0, p1, color, 4, cv2.LINE_AA) # Baricentro cx, cy = int(round(m.cx)), int(round(m.cy)) cv2.drawMarker(out, (cx, cy), color, cv2.MARKER_CROSS, 22, 2, cv2.LINE_AA) cv2.circle(out, (cx, cy), 4, color, -1, cv2.LINE_AA) # Asse orientamento L = int(np.linalg.norm(m.bbox_poly[1] - m.bbox_poly[0])) // 2 a = np.deg2rad(m.angle_deg) ex = int(round(cx + L * np.cos(a))) ey = int(round(cy - L * np.sin(a))) cv2.arrowedLine(out, (cx, cy), (ex, ey), color, 2, cv2.LINE_AA, tipLength=0.2) # Etichetta label = f"#{i+1} {m.angle_deg:.0f}d s={m.scale:.2f} {m.score:.2f}" cv2.putText(out, label, (cx + 8, cy - 8), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 2, cv2.LINE_AA) return out def build_info_panel( template_bgr: np.ndarray, params: dict, matches: list[Match], panel_width: int = 380, panel_height: int | None = None, ) -> np.ndarray: """Costruisce pannello laterale: thumbnail modello + parametri + legenda numerata dei match + hotkey.""" if panel_height is None: panel_height = panel_width * 2 panel = np.full((panel_height, panel_width, 3), 28, dtype=np.uint8) pad = 12 y = pad def _text(img, s, y, size=0.5, color=(220, 220, 220), thick=1, x=None): cv2.putText(img, s, (x if x is not None else pad, y), cv2.FONT_HERSHEY_SIMPLEX, size, color, thick, cv2.LINE_AA) # Titolo _text(panel, "MODELLO", y + 18, size=0.7, color=(0, 200, 255), thick=2) y += 34 # Thumbnail modello th_h, th_w = template_bgr.shape[:2] max_tw = panel_width - 2 * pad max_th = 150 sc = min(max_tw / th_w, max_th / th_h) tw = max(1, int(th_w * sc)); th = max(1, int(th_h * sc)) thumb = cv2.resize(template_bgr, (tw, th), interpolation=cv2.INTER_AREA) if thumb.ndim == 2: thumb = cv2.cvtColor(thumb, cv2.COLOR_GRAY2BGR) tx = (panel_width - tw) // 2 panel[y:y + th, tx:tx + tw] = thumb cv2.rectangle(panel, (tx - 1, y - 1), (tx + tw, y + th), (90, 90, 90), 1, cv2.LINE_AA) y += th + 12 # Parametri _text(panel, "PARAMETRI", y, size=0.55, color=(0, 200, 255), thick=2) y += 20 for k, v in params.items(): _text(panel, f"{k}: {v}", y, size=0.42) y += 16 y += 6 _text(panel, f"RISULTATI ({len(matches)})", y, size=0.55, color=(0, 200, 255), thick=2) y += 20 if matches: scores = [m.score for m in matches] scales = [m.scale for m in matches] _text(panel, f"score: {min(scores):.2f}..{max(scores):.2f}", y, size=0.42); y += 16 if max(scales) != min(scales): _text(panel, f"scale: {min(scales):.2f}..{max(scales):.2f}", y, size=0.42); y += 16 # Legenda numerata con colore per ogni match max_rows = max(1, (panel_height - y - 120) // 16) shown = matches[:max_rows] for i, m in enumerate(shown): color = _color_for(i) # Pallino di colore cv2.circle(panel, (pad + 6, y - 4), 5, color, -1, cv2.LINE_AA) txt = (f"#{i+1} ({int(m.cx)},{int(m.cy)}) " f"{m.angle_deg:.0f}d s={m.scale:.2f} {m.score:.3f}") _text(panel, txt, y, size=0.40, x=pad + 18) y += 16 if len(matches) > len(shown): _text(panel, f"... +{len(matches) - len(shown)} altri", y, size=0.40, color=(150, 150, 150)); y += 16 # Hotkey in fondo footer_y = panel_height - 92 _text(panel, "HOTKEY", footer_y, size=0.55, color=(0, 200, 255), thick=2) fy = footer_y + 18 for line in [ "r modifica parametri", "o nuovo ROI (stesso modello)", "m nuovo file modello", "s nuova scena", "q / Esc esci", ]: _text(panel, line, fy, size=0.40, color=(180, 180, 180)) fy += 14 return panel def compose_result( scene_annotated: np.ndarray, panel: np.ndarray, ) -> np.ndarray: """Affianca pannello a sinistra + scena a destra, altezza uniforme.""" sH, sW = scene_annotated.shape[:2] pH, pW = panel.shape[:2] if pH != sH: sc = sH / pH new_pW = max(1, int(pW * sc)) panel = cv2.resize(panel, (new_pW, sH), interpolation=cv2.INTER_AREA) pW = new_pW out = np.zeros((sH, pW + sW, 3), dtype=np.uint8) out[:, :pW] = panel out[:, pW:] = scene_annotated return out def _color_for(i: int) -> tuple[int, int, int]: 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), ] return palette[i % len(palette)] def show_results( scene: np.ndarray, matches: list[Match], template_bgr: np.ndarray | None = None, params: dict | None = None, ) -> str: """Visualizza risultati. Ritorna 'rematch' se l'utente preme 'r', altrimenti 'quit'.""" print(f"\n=== {len(matches)} match trovati ===") for i, m in enumerate(matches): print(f" #{i+1}: cx={m.cx:.1f} cy={m.cy:.1f} " f"angle={m.angle_deg:.1f}d scale={m.scale:.2f} score={m.score:.3f}") template_gray = None if template_bgr is not None: template_gray = (template_bgr if template_bgr.ndim == 2 else cv2.cvtColor(template_bgr, cv2.COLOR_BGR2GRAY)) annotated = draw_matches(scene, matches, template_gray=template_gray) if template_bgr is not None and params is not None: panel = build_info_panel(template_bgr, params, matches, panel_height=annotated.shape[0]) composed = compose_result(annotated, panel) else: composed = annotated disp = _fit_for_display(composed, max_side=1600) cv2.imshow(WINDOW_RESULT, disp) print("\n[r] parametri [o] nuovo ROI [m] nuovo modello [s] nuova scena [q/Esc] chiudi") action = "quit" while True: k = cv2.waitKey(0) & 0xFF if k in (ord("r"), ord("R")): action = "rematch"; break if k in (ord("o"), ord("O")): action = "new_roi"; break if k in (ord("m"), ord("M")): action = "new_model"; break if k in (ord("s"), ord("S")): action = "new_scene"; break if k in (27, ord("q"), ord("Q")): action = "quit"; break if k != 255: action = "quit"; break cv2.destroyAllWindows() return action def run( initial_dir: str | None = None, angle_step_deg: float = 5.0, angle_range_deg: tuple[float, float] = (0.0, 360.0), scale_range: tuple[float, float] = (1.0, 1.0), scale_step: float = 0.1, num_features: int = 96, weak_grad: float = 30.0, strong_grad: float = 60.0, spread_radius: int = 5, pyramid_levels: int = 3, min_score: float = 0.55, max_matches: int = 25, nms_radius: int = 0, backend: str = "line", ) -> None: """Entry-point GUI completo.""" print("[1] Selezionare immagine MODELLO...") model_path = pick_file("Immagine MODELLO", initialdir=initial_dir) if not model_path: print("Annullato."); return model_img = load_image(model_path) print(f" caricato: {model_path} shape={model_img.shape}") print("[2] Selezionare ROI sul modello (trascinare, INVIO conferma).") roi = select_roi(model_img) if roi is None: print("ROI vuota, annullato."); return print(f" ROI: {roi.shape[1]}x{roi.shape[0]} px") print("[3] Selezionare immagine SCENA...") scene_path = pick_file("Immagine SCENA", initialdir=str(Path(model_path).parent)) if not scene_path: print("Annullato."); return scene = load_image(scene_path) print(f" caricato: {scene_path} shape={scene.shape}") # Valori iniziali del form parametri cur = { "backend": backend, "angle_min": angle_range_deg[0], "angle_max": angle_range_deg[1], "angle_step": angle_step_deg, "scale_min": scale_range[0], "scale_max": scale_range[1], "scale_step": scale_step, "min_score": min_score, "max_matches": max_matches, "nms_radius": nms_radius, "num_features": num_features, "weak_grad": weak_grad, "strong_grad": strong_grad, "spread_radius": spread_radius, "pyramid_levels": pyramid_levels, } while True: print("[4/?] Dialog parametri (OK=conferma, Annulla=esci)...") new = edit_params(cur, template_bgr=roi) if new is None: print("Annullato."); return cur = new print(f" Train + match (backend={cur['backend']})...") if cur["backend"] == "edge": matcher: EdgeShapeMatcher | LineShapeMatcher = EdgeShapeMatcher( angle_step_deg=cur["angle_step"], angle_range_deg=(cur["angle_min"], cur["angle_max"]), scale_range=(cur["scale_min"], cur["scale_max"]), scale_step=cur["scale_step"], ) else: matcher = LineShapeMatcher( num_features=cur["num_features"], weak_grad=cur["weak_grad"], strong_grad=cur["strong_grad"], angle_step_deg=cur["angle_step"], angle_range_deg=(cur["angle_min"], cur["angle_max"]), scale_range=(cur["scale_min"], cur["scale_max"]), scale_step=cur["scale_step"], spread_radius=cur["spread_radius"], pyramid_levels=cur["pyramid_levels"], ) import time t0 = time.time() n = matcher.train(roi) t_train = time.time() - t0 print(f" train: {n} varianti in {t_train:.2f}s") t0 = time.time() nms = cur["nms_radius"] if cur["nms_radius"] > 0 else None matches = matcher.find( scene, min_score=cur["min_score"], max_matches=cur["max_matches"], nms_radius=nms, ) t_find = time.time() - t0 print(f" find: {len(matches)} match in {t_find:.2f}s") params = { "backend": cur["backend"], "angle_range": f"{cur['angle_min']:.0f}..{cur['angle_max']:.0f}d", "angle_step": f"{cur['angle_step']:.1f}d", "scale_range": f"{cur['scale_min']:.2f}..{cur['scale_max']:.2f}", "scale_step": f"{cur['scale_step']:.2f}", "min_score": f"{cur['min_score']:.2f}", "max_matches": str(cur["max_matches"]), "nms_radius": str(nms if nms else "auto"), "num_variants": str(n), "train_time": f"{t_train:.2f}s", "find_time": f"{t_find:.2f}s", } if cur["backend"] == "line": params["num_features"] = str(cur["num_features"]) params["weak/strong"] = f"{cur['weak_grad']:.0f}/{cur['strong_grad']:.0f}" params["spread_radius"] = str(cur["spread_radius"]) params["pyramid_levels"] = str(cur["pyramid_levels"]) action = show_results(scene, matches, template_bgr=roi, params=params) if action == "rematch": continue if action == "new_roi": new_roi = select_roi(model_img) if new_roi is None: print("ROI annullata, esco.") break roi = new_roi print(f" nuovo ROI: {roi.shape[1]}x{roi.shape[0]} px") continue if action == "new_model": p = pick_file("Nuovo MODELLO", initialdir=str(Path(model_path).parent)) if not p: print("Annullato."); break model_path = p model_img = load_image(model_path) print(f" modello: {model_path} shape={model_img.shape}") new_roi = select_roi(model_img) if new_roi is None: print("ROI annullata, esco."); break roi = new_roi print(f" nuovo ROI: {roi.shape[1]}x{roi.shape[0]} px") continue if action == "new_scene": p = pick_file("Nuova SCENA", initialdir=str(Path(scene_path).parent)) if not p: print("Annullato."); break scene_path = p scene = load_image(scene_path) print(f" scena: {scene_path} shape={scene.shape}") continue # quit break if __name__ == "__main__": test_dir = "/home/adriano/Documenti/Git_XYZ/VisionSuite/Shape_model_2d/Test" run(initial_dir=test_dir if Path(test_dir).is_dir() else None)