Files
Shape_Model_2D/pm2d/gui.py
T
Adriano d27676cfe6 fix: cap adattivo al 50% varianti + form e finestra risultati ridimensionabili
- max_vars_full = max(max_matches*8, n_variants // 2): protegge perf con
  molte scale mantenendo metà delle varianti al full-res (vs intero senza cap
  che dava 22s su 864 varianti, vs 80s screenshot utente)
- Dialog tkinter: resizable, minsize 360x420, Entry col peso 2 espandibile
- Finestra risultati cv2: WINDOW_NORMAL con resizeWindow iniziale 1600x900

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 01:57:20 +02:00

592 lines
21 KiB
Python

"""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("<Return>", lambda _e: on_ok())
root.bind("<Escape>", 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.namedWindow(WINDOW_RESULT, cv2.WINDOW_NORMAL)
cv2.resizeWindow(WINDOW_RESULT, min(disp.shape[1], 1600),
min(disp.shape[0], 900))
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,
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)