Files
Shape_Model_2D/pm2d/gui.py
T
Adriano 4ddda1ec62 ui: layout fisso 1600x900 con pannelli SX/DX, scena scalata
- Finestra dimensione fissa: scena scalata fit-to-box mantenendo aspect
  ratio (anche immagini piccole riempiono il layout)
- Pannello sinistro: MODELLO thumbnail + RISULTATI legenda numerata
- Pannello destro: PARAMETRI sempre visibili (train/find time evidenziati)
  + HOTKEY
- Rimossi parametri duplicati da pannello sinistro

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

651 lines
23 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 _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)