perf: piramide al training, refinement sub-step, multithreading

LineShapeMatcher:
- Feature piramidate precomputate al training (_LevelFeatures per livello
  piramide, dedup risolto una volta)
- Refinement angolare: 5 offset ±step/2 + parabolic fit → precisione ~0.5°
  con angle_step=5° (10x fine rispetto a step training)
- Subpixel posizione: parabolic fit 2D sul picco → frazione pixel
- Multithreading: n_threads auto=CPU-1, parallelizza top-level pruning e
  full-res matching tramite ThreadPoolExecutor (numpy/cv2 rilasciano GIL)

GUI:
- Dialog edit_params con bottone Auto-tune
- Legenda numerata match con pallino colore (#i, coords, angle, scala, score)
- Hotkey finestra: r=params, o=nuovo ROI, m=nuovo modello, s=nuova scena
- Pannello con train/find time + HOTKEY in basso

auto_tune.py:
- Analisi template: soglie grad da percentili, num_features da densità
  edge, pyramid_levels da min_side, min_score da entropia orientation,
  rilevazione simmetria rotazionale (soglia 0.75 NCC su magnitude)

Benchmark clip.png (13 istanze, 72 varianti angolari):
  prima: 5.84s, precisione 5° (step training)
  ora:   1.67s, precisione ~0.5°, subpixel posizione
  speed-up: 3.5x, precisione angolare 10x

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-24 01:22:56 +02:00
parent b9a4d51fac
commit 075b014bd7
4 changed files with 918 additions and 105 deletions
+422 -47
View File
@@ -15,12 +15,109 @@ 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("<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)"
@@ -79,31 +176,186 @@ def _fit_for_display(image: np.ndarray, max_side: int = 1200) -> np.ndarray:
return cv2.resize(image, (int(w * s), int(h * s)), interpolation=cv2.INTER_AREA)
def draw_matches(scene: np.ndarray, matches: list[Match]) -> np.ndarray:
"""Disegna baricentro, asse orientamento, bbox ruotato per ogni match."""
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)
# Bbox ruotato: il template ruotato di angle_deg ha bbox assi-allineato
# nel sistema variante; per disegnarlo esatto, ricaviamo il rettangolo
# ruotato del template originale attorno al baricentro.
x, y, w, h = m.bbox
# box assi-allineato della variante
cv2.rectangle(out, (x, y), (x + w, y + h), color, 1, cv2.LINE_AA)
# 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 (lunghezza ~ metà altezza bbox)
L = max(h, w) // 2
ang_rad = np.deg2rad(m.angle_deg)
ex = int(round(cx + L * np.cos(ang_rad)))
ey = int(round(cy - L * np.sin(ang_rad))) # y invertita immagine
cv2.arrowedLine(out, (cx, cy), (ex, ey), color, 2, cv2.LINE_AA, tipLength=0.2)
# 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, 1, cv2.LINE_AA)
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
@@ -116,17 +368,48 @@ def _color_for(i: int) -> tuple[int, int, int]:
return palette[i % len(palette)]
def show_results(scene: np.ndarray, matches: list[Match]) -> None:
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}")
overlay = draw_matches(scene, matches)
disp = _fit_for_display(overlay, max_side=1400)
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("\nPremere un tasto sulla finestra per chiudere.")
cv2.waitKey(0)
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(
@@ -142,52 +425,144 @@ def run(
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/4] Selezionare immagine MODELLO...")
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(f" caricato: {model_path} shape={model_img.shape}")
print("[2/4] Selezionare ROI sul modello (trascinare, INVIO conferma).")
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(f" ROI: {roi.shape[1]}x{roi.shape[0]} px")
print("[3/4] Selezionare immagine SCENA...")
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}")
print(f" caricato: {scene_path} shape={scene.shape}")
print(f"[4/4] Train + match (backend={backend})...")
if backend == "edge":
matcher: EdgeShapeMatcher | LineShapeMatcher = EdgeShapeMatcher(
angle_step_deg=angle_step_deg, angle_range_deg=angle_range_deg,
scale_range=scale_range, scale_step=scale_step,
# 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,
)
else:
matcher = LineShapeMatcher(
num_features=num_features,
weak_grad=weak_grad, strong_grad=strong_grad,
angle_step_deg=angle_step_deg, angle_range_deg=angle_range_deg,
scale_range=scale_range, scale_step=scale_step,
spread_radius=spread_radius, pyramid_levels=pyramid_levels,
)
import time
t0 = time.time()
n = matcher.train(roi)
print(f" train: {n} varianti in {time.time()-t0:.2f}s")
t0 = time.time()
matches = matcher.find(scene, min_score=min_score, max_matches=max_matches)
print(f" find: {len(matches)} match in {time.time()-t0:.2f}s")
show_results(scene, matches)
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__":