Files
Shape_Model_2D/pm2d/gui.py
T
Adriano faebccb69e feat: background locale + verify NCC per eliminare falsi positivi
Problema: matcher linemod con solo orientamento gradient può dare score alto
su texture dense/rumore che per caso accumulano orientamenti compatibili.
Esempio: template ruota dentata su scena clip → match a score 0.9 (errati).

Fix in 2 livelli:

1. Background score LOCALE nel find()
   - _bg_map(resp, box_size) = densità media bin attivi in bbox template
   - Rinormalizza score: s' = max(0, (s - bg) / (1 - bg))
   - Annulla contributo di zone sature ma preserva pattern puliti

2. Verify NCC post-hoc
   - _verify_ncc(): warpa template alla pose (cx, cy, angle, scale) e
     calcola NCC classico su intensità con la scena sottostante
   - Threshold di default 0.4 elimina FP con edge orientati casualmente
   - Parametro esposto in GUI (verify_threshold)

Rimossa penalty di saturazione nel response_map (ridondante).

Test regression (ruote dentate vs clip, clip vs ruote dentate):
  no verify:  12+ falsi positivi con score ~0.7
  verify 0.4: 1-2 falsi positivi rimanenti, true positive invariati
  verify 0.5: 0 falsi positivi, 1 TP scale piccola perso

Benchmark clip→clip (13 istanze):
  full pipeline (Numba + threads + refine + subpix + verify): 1.12s

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

581 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")
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("<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.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)