"""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), ("verify_threshold", "Verify NCC threshold", float), ] 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") root.resizable(True, True) root.minsize(360, 420) try: root.attributes("-topmost", True) except Exception: pass # Grid root: fa espandere la cella (0, 0) root.columnconfigure(0, weight=1) root.rowconfigure(0, weight=1) result: dict = {} entries: dict[str, tk.Entry] = {} frame = ttk.Frame(root, padding=12) frame.grid(row=0, column=0, sticky="nsew") frame.columnconfigure(0, weight=1) frame.columnconfigure(1, weight=2) 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) e.insert(0, str(defaults.get(key, ""))) e.grid(row=i, column=1, padx=4, pady=3, sticky="ew") 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 _put_text(img: np.ndarray, s: str, x: int, y: int, size: float = 0.5, color: tuple = (220, 220, 220), thick: int = 1) -> None: cv2.putText(img, s, (x, y), cv2.FONT_HERSHEY_SIMPLEX, size, color, thick, cv2.LINE_AA) def build_left_panel( template_bgr: np.ndarray, matches: list[Match], panel_width: int = 300, panel_height: int = 900, ) -> np.ndarray: """Pannello sinistro: thumbnail modello + legenda risultati (senza parametri).""" panel = np.full((panel_height, panel_width, 3), 28, dtype=np.uint8) pad = 12 y = pad # Titolo MODELLO _put_text(panel, "MODELLO", pad, y + 18, size=0.7, color=(0, 200, 255), thick=2) y += 34 # Thumbnail th_h, th_w = template_bgr.shape[:2] max_tw = panel_width - 2 * pad max_th = 160 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 + 16 # Risultati _put_text(panel, f"RISULTATI ({len(matches)})", pad, y, size=0.6, color=(0, 200, 255), thick=2) y += 22 if matches: scores = [m.score for m in matches] scales = [m.scale for m in matches] _put_text(panel, f"score: {min(scores):.2f}..{max(scores):.2f}", pad, y, size=0.42); y += 16 if max(scales) != min(scales): _put_text(panel, f"scale: {min(scales):.2f}..{max(scales):.2f}", pad, y, size=0.42); y += 16 y += 4 max_rows = max(1, (panel_height - y - 12) // 16) shown = matches[:max_rows] for i, m in enumerate(shown): color = _color_for(i) 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}") _put_text(panel, txt, pad + 18, y, size=0.40) y += 16 if len(matches) > len(shown): _put_text(panel, f"... +{len(matches) - len(shown)} altri", pad, y, size=0.40, color=(150, 150, 150)) return panel def build_right_panel( params: dict, panel_width: int = 320, panel_height: int = 900, ) -> np.ndarray: """Pannello destro: parametri correnti + hotkey.""" panel = np.full((panel_height, panel_width, 3), 28, dtype=np.uint8) pad = 12 y = pad _put_text(panel, "PARAMETRI", pad, y + 18, size=0.7, color=(0, 200, 255), thick=2) y += 36 # Tempi in alto, evidenziati for k in ("train_time", "find_time", "num_variants"): if k in params: v = params[k] _put_text(panel, f"{k}:", pad, y, size=0.45, color=(160, 220, 160)) _put_text(panel, str(v), pad + 140, y, size=0.45, color=(200, 255, 200), thick=2) y += 18 y += 6 # Altri parametri skip = {"train_time", "find_time", "num_variants"} for k, v in params.items(): if k in skip: continue _put_text(panel, f"{k}:", pad, y, size=0.42, color=(180, 180, 180)) _put_text(panel, str(v), pad + 140, y, size=0.42, color=(220, 220, 220)) y += 16 # Hotkey in fondo footer_y = panel_height - 110 _put_text(panel, "HOTKEY", pad, footer_y, size=0.6, color=(0, 200, 255), thick=2) fy = footer_y + 22 for line in [ "r modifica parametri", "o nuovo ROI", "m nuovo modello", "s nuova scena", "q / Esc esci", ]: _put_text(panel, line, pad, fy, size=0.42, color=(200, 200, 200)) fy += 16 return panel def _fit_scene_center( scene: np.ndarray, target_w: int, target_h: int, ) -> np.ndarray: """Scala scena a fit (target_w, target_h) mantenendo aspect; padding bg.""" h, w = scene.shape[:2] sc = min(target_w / w, target_h / h) new_w = max(1, int(w * sc)); new_h = max(1, int(h * sc)) resized = cv2.resize(scene, (new_w, new_h), interpolation=cv2.INTER_AREA) out = np.full((target_h, target_w, 3), 20, dtype=np.uint8) y0 = (target_h - new_h) // 2 x0 = (target_w - new_w) // 2 out[y0:y0 + new_h, x0:x0 + new_w] = resized return out def compose_fixed_layout( scene_annotated: np.ndarray, left_panel: np.ndarray, right_panel: np.ndarray, window_w: int = 1600, window_h: int = 900, ) -> np.ndarray: """Layout fisso: [left | scena fit-scaled | right].""" lH, lW = left_panel.shape[:2] rH, rW = right_panel.shape[:2] # Altezza uniforme (pannelli dovrebbero essere giĆ  window_h) if lH != window_h: left_panel = cv2.resize(left_panel, (lW, window_h), interpolation=cv2.INTER_AREA) if rH != window_h: right_panel = cv2.resize(right_panel, (rW, window_h), interpolation=cv2.INTER_AREA) center_w = window_w - lW - rW center = _fit_scene_center(scene_annotated, center_w, window_h) out = np.concatenate([left_panel, center, right_panel], axis=1) 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, window_w: int = 1600, window_h: int = 900, ) -> str: """Visualizza risultati in layout fisso [SX panel | scena scalata | DX panel]. Ritorna 'rematch'/'new_roi'/'new_model'/'new_scene'/'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: left = build_left_panel(template_bgr, matches, panel_height=window_h) else: left = np.full((window_h, 300, 3), 28, dtype=np.uint8) if params is not None: right = build_right_panel(params, panel_height=window_h) else: right = np.full((window_h, 320, 3), 28, dtype=np.uint8) composed = compose_fixed_layout( annotated, left, right, window_w=window_w, window_h=window_h, ) cv2.namedWindow(WINDOW_RESULT, cv2.WINDOW_AUTOSIZE) cv2.imshow(WINDOW_RESULT, composed) 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, verify_threshold: float = 0.4, 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, "verify_threshold": verify_threshold, } 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 if cur["backend"] == "line": matches = matcher.find( scene, min_score=cur["min_score"], max_matches=cur["max_matches"], nms_radius=nms, verify_threshold=cur.get("verify_threshold", 0.4), ) else: 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)