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
+211
View File
@@ -0,0 +1,211 @@
"""Auto-tune parametri PM2D da analisi del template.
Analizza la ROI del modello e suggerisce valori ragionevoli per i principali
parametri del `LineShapeMatcher`, tenendo conto di:
- **distribuzione magnitude del gradiente** → soglie `weak_grad` / `strong_grad`
- **numero di edge utili** → `num_features`
- **dimensione template** → `pyramid_levels`, `spread_radius`
- **simmetria rotazionale** (autocorrelazione su rotazione) → `angle_range_deg`
- **entropia orientamenti** → suggerimento `min_score`
Ritorna dict con i key esatti del form `edit_params`.
"""
from __future__ import annotations
import cv2
import numpy as np
def _to_gray(img: np.ndarray) -> np.ndarray:
if img.ndim == 3:
return cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
return img
def detect_rotational_symmetry(
gray: np.ndarray, step_deg: float = 5.0, corr_thresh: float = 0.75,
) -> dict:
"""Rileva simmetria rotazionale su edge map (più robusto a sfondo uniforme).
Ritorna dict con:
- order: int, 1=nessuna, 2=180°, 3=120°, 4=90°, 6=60°, 8=45°
- period_deg: float, periodo minimo di simmetria (360/order)
- confidence: float [0..1], correlazione minima tra rotazioni equivalenti
"""
h, w = gray.shape
# Usa magnitude gradiente (rotation-invariant rispetto a bg uniforme)
gx = cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3)
gy = cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3)
mag = cv2.magnitude(gx, gy).astype(np.float32)
center = (w / 2.0, h / 2.0)
ref = mag
correlations: list[tuple[float, float]] = []
for ang in np.arange(step_deg, 360.0, step_deg):
M = cv2.getRotationMatrix2D(center, float(ang), 1.0)
rot = cv2.warpAffine(
mag, M, (w, h), borderValue=0.0,
)
rm = ref - ref.mean()
rs = rot - rot.mean()
denom = np.sqrt((rm * rm).sum() * (rs * rs).sum()) + 1e-9
c = float((rm * rs).sum() / denom)
correlations.append((float(ang), c))
# Candidati simmetria: 2,3,4,6,8 (90/45)
candidates = [2, 3, 4, 6, 8]
best_order = 1
best_conf = 0.0
for order in candidates:
period = 360.0 / order
# Verifica che ALLE rotazioni n*period (n=1..order-1) ci sia alta corr
corrs = []
for n in range(1, order):
target = period * n
# trova angolo più vicino in correlations
closest = min(correlations, key=lambda p: abs(p[0] - target))
if abs(closest[0] - target) > step_deg * 1.5:
corrs.append(0.0)
else:
corrs.append(closest[1])
conf = min(corrs) if corrs else 0.0
if conf >= corr_thresh and conf > best_conf:
best_order = order
best_conf = conf
return {
"order": best_order,
"period_deg": 360.0 / best_order,
"confidence": best_conf,
}
def analyze_gradients(gray: np.ndarray) -> dict:
"""Statistiche magnitude / orientation gradiente."""
gx = cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3)
gy = cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3)
mag = cv2.magnitude(gx, gy)
# Percentili magnitude
p50 = float(np.percentile(mag, 50))
p80 = float(np.percentile(mag, 80))
p95 = float(np.percentile(mag, 95))
mag_max = float(mag.max())
# Numero pixel "forti"
strong_pct = float((mag > p95).sum()) / mag.size
weak_pct = float((mag > p50).sum()) / mag.size
# Entropia orientamenti (solo pixel forti)
ang = np.arctan2(gy, gx)
ang_mod = np.where(ang < 0, ang + np.pi, ang)
mask = mag > p80
if mask.sum() > 10:
bins_count, _ = np.histogram(
ang_mod[mask], bins=16, range=(0, np.pi),
)
p = bins_count / (bins_count.sum() + 1e-9)
ent = float(-np.sum(p * np.log(p + 1e-9)) / np.log(16))
else:
ent = 0.0
return {
"p50": p50, "p80": p80, "p95": p95, "mag_max": mag_max,
"strong_pct": strong_pct, "weak_pct": weak_pct,
"orient_entropy": ent,
"n_pixels": mag.size,
"n_strong": int((mag > p95).sum()),
}
def auto_tune(template_bgr: np.ndarray, mask: np.ndarray | None = None) -> dict:
"""Analizza template e ritorna dict parametri suggeriti.
Chiavi compatibili con edit_params PARAM_SCHEMA.
"""
gray = _to_gray(template_bgr)
h, w = gray.shape
if mask is not None:
# Zero fuori maschera per statistiche
gray_for_stats = np.where(mask > 0, gray, int(np.median(gray))).astype(np.uint8)
else:
gray_for_stats = gray
stats = analyze_gradients(gray_for_stats)
sym = detect_rotational_symmetry(gray_for_stats)
# Soglie magnitude: usa percentili per robustezza illuminazione.
# Target: strong_grad ~= valore a percentile 80-90 in assoluto, ma
# clamp per compatibilità uint8 (Sobel può sforare).
strong_grad = float(np.clip(stats["p80"], 20.0, 100.0))
weak_grad = float(np.clip(strong_grad * 0.5, 10.0, 60.0))
# num_features: 1 feature ogni ~25 px forti, clamp 48..192
target_feat = int(np.clip(stats["n_strong"] / 25, 48, 192))
# pyramid_levels in base alla dimensione minima
min_side = min(h, w)
if min_side < 60:
pyr = 1
elif min_side < 120:
pyr = 2
elif min_side < 320:
pyr = 3
else:
pyr = 4
# spread_radius proporzionale a risoluzione + pyramid (tolleranza ~1% dim)
spread_radius = int(np.clip(max(3, min_side * 0.02), 3, 8))
# angle range ridotto se simmetria rotazionale
angle_max = 360.0 / sym["order"] if sym["order"] > 1 else 360.0
# min_score: se entropia orient alta → template distintivo → soglia alta ok
# se entropia bassa → template ambiguo → soglia più permissiva
if stats["orient_entropy"] > 0.75:
min_score = 0.65
elif stats["orient_entropy"] > 0.55:
min_score = 0.55
else:
min_score = 0.45
# angle step: 5° default; se simmetria, mantengo step ma range ridotto
angle_step = 5.0
return {
"backend": "line",
"angle_min": 0.0,
"angle_max": angle_max,
"angle_step": angle_step,
"scale_min": 1.0,
"scale_max": 1.0,
"scale_step": 0.1,
"min_score": round(min_score, 2),
"max_matches": 25,
"nms_radius": 0,
"num_features": target_feat,
"weak_grad": round(weak_grad, 1),
"strong_grad": round(strong_grad, 1),
"spread_radius": spread_radius,
"pyramid_levels": pyr,
# meta (non in PARAM_SCHEMA, usato per log)
"_symmetry_order": sym["order"],
"_symmetry_conf": round(sym["confidence"], 2),
"_orient_entropy": round(stats["orient_entropy"], 2),
}
def summarize(tune: dict) -> str:
"""Stringa one-line delle scelte principali."""
so = tune.get("_symmetry_order", 1)
sc = tune.get("_symmetry_conf", 0)
ent = tune.get("_orient_entropy", 0)
return (
f"sym={so}x (conf={sc:.2f}) entropia={ent:.2f} "
f"feat={tune['num_features']} pyr={tune['pyramid_levels']} "
f"grad={tune['weak_grad']:.0f}/{tune['strong_grad']:.0f} "
f"ang=[0..{tune['angle_max']:.0f}]@{tune['angle_step']:.0f}d "
f"min_score={tune['min_score']}"
)
+422 -47
View File
@@ -15,12 +15,109 @@ from __future__ import annotations
import sys import sys
from pathlib import Path from pathlib import Path
from tkinter import Tk, filedialog from tkinter import Tk, filedialog
import tkinter as tk
from tkinter import ttk
import cv2 import cv2
import numpy as np import numpy as np
from pm2d.matcher import EdgeShapeMatcher from pm2d.matcher import EdgeShapeMatcher
from pm2d.line_matcher import LineShapeMatcher, Match 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)" 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) 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: def _warp_template_edges_to_scene(
"""Disegna baricentro, asse orientamento, bbox ruotato per ogni match.""" 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() out = scene.copy()
H, W = scene.shape[:2]
for i, m in enumerate(matches): for i, m in enumerate(matches):
color = _color_for(i) color = _color_for(i)
# Bbox ruotato: il template ruotato di angle_deg ha bbox assi-allineato # Overlay edge template nella pose del match (se template disponibile)
# nel sistema variante; per disegnarlo esatto, ricaviamo il rettangolo if template_gray is not None:
# ruotato del template originale attorno al baricentro. emap = _warp_template_edges_to_scene(
x, y, w, h = m.bbox template_gray, m.cx, m.cy, m.angle_deg, m.scale, (H, W),
# box assi-allineato della variante canny_low=canny_for_overlay[0], canny_high=canny_for_overlay[1],
cv2.rectangle(out, (x, y), (x + w, y + h), color, 1, cv2.LINE_AA) )
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 # Baricentro
cx, cy = int(round(m.cx)), int(round(m.cy)) 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.drawMarker(out, (cx, cy), color, cv2.MARKER_CROSS, 22, 2, cv2.LINE_AA)
cv2.circle(out, (cx, cy), 4, color, -1, cv2.LINE_AA) cv2.circle(out, (cx, cy), 4, color, -1, cv2.LINE_AA)
# Asse orientamento (lunghezza ~ metà altezza bbox) # Asse orientamento
L = max(h, w) // 2 L = int(np.linalg.norm(m.bbox_poly[1] - m.bbox_poly[0])) // 2
ang_rad = np.deg2rad(m.angle_deg) a = np.deg2rad(m.angle_deg)
ex = int(round(cx + L * np.cos(ang_rad))) ex = int(round(cx + L * np.cos(a)))
ey = int(round(cy - L * np.sin(ang_rad))) # y invertita immagine ey = int(round(cy - L * np.sin(a)))
cv2.arrowedLine(out, (cx, cy), (ex, ey), color, 2, cv2.LINE_AA, tipLength=0.2) cv2.arrowedLine(out, (cx, cy), (ex, ey), color, 2,
cv2.LINE_AA, tipLength=0.2)
# Etichetta # Etichetta
label = f"#{i+1} {m.angle_deg:.0f}d s={m.scale:.2f} {m.score:.2f}" 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.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 return out
@@ -116,17 +368,48 @@ def _color_for(i: int) -> tuple[int, int, int]:
return palette[i % len(palette)] 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 ===") print(f"\n=== {len(matches)} match trovati ===")
for i, m in enumerate(matches): for i, m in enumerate(matches):
print(f" #{i+1}: cx={m.cx:.1f} cy={m.cy:.1f} " 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}") f"angle={m.angle_deg:.1f}d scale={m.scale:.2f} score={m.score:.3f}")
overlay = draw_matches(scene, matches) template_gray = None
disp = _fit_for_display(overlay, max_side=1400) 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) cv2.imshow(WINDOW_RESULT, disp)
print("\nPremere un tasto sulla finestra per chiudere.") print("\n[r] parametri [o] nuovo ROI [m] nuovo modello [s] nuova scena [q/Esc] chiudi")
cv2.waitKey(0) 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() cv2.destroyAllWindows()
return action
def run( def run(
@@ -142,52 +425,144 @@ def run(
pyramid_levels: int = 3, pyramid_levels: int = 3,
min_score: float = 0.55, min_score: float = 0.55,
max_matches: int = 25, max_matches: int = 25,
nms_radius: int = 0,
backend: str = "line", backend: str = "line",
) -> None: ) -> None:
"""Entry-point GUI completo.""" """Entry-point GUI completo."""
print("[1/4] Selezionare immagine MODELLO...") print("[1] Selezionare immagine MODELLO...")
model_path = pick_file("Immagine MODELLO", initialdir=initial_dir) model_path = pick_file("Immagine MODELLO", initialdir=initial_dir)
if not model_path: if not model_path:
print("Annullato."); return print("Annullato."); return
model_img = load_image(model_path) 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) roi = select_roi(model_img)
if roi is None: if roi is None:
print("ROI vuota, annullato."); return 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", scene_path = pick_file("Immagine SCENA",
initialdir=str(Path(model_path).parent)) initialdir=str(Path(model_path).parent))
if not scene_path: if not scene_path:
print("Annullato."); return print("Annullato."); return
scene = load_image(scene_path) 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})...") # Valori iniziali del form parametri
if backend == "edge": cur = {
matcher: EdgeShapeMatcher | LineShapeMatcher = EdgeShapeMatcher( "backend": backend,
angle_step_deg=angle_step_deg, angle_range_deg=angle_range_deg, "angle_min": angle_range_deg[0],
scale_range=scale_range, scale_step=scale_step, "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: t_find = time.time() - t0
matcher = LineShapeMatcher( print(f" find: {len(matches)} match in {t_find:.2f}s")
num_features=num_features,
weak_grad=weak_grad, strong_grad=strong_grad, params = {
angle_step_deg=angle_step_deg, angle_range_deg=angle_range_deg, "backend": cur["backend"],
scale_range=scale_range, scale_step=scale_step, "angle_range": f"{cur['angle_min']:.0f}..{cur['angle_max']:.0f}d",
spread_radius=spread_radius, pyramid_levels=pyramid_levels, "angle_step": f"{cur['angle_step']:.1f}d",
) "scale_range": f"{cur['scale_min']:.2f}..{cur['scale_max']:.2f}",
import time "scale_step": f"{cur['scale_step']:.2f}",
t0 = time.time() "min_score": f"{cur['min_score']:.2f}",
n = matcher.train(roi) "max_matches": str(cur["max_matches"]),
print(f" train: {n} varianti in {time.time()-t0:.2f}s") "nms_radius": str(nms if nms else "auto"),
t0 = time.time() "num_variants": str(n),
matches = matcher.find(scene, min_score=min_score, max_matches=max_matches) "train_time": f"{t_train:.2f}s",
print(f" find: {len(matches)} match in {time.time()-t0:.2f}s") "find_time": f"{t_find:.2f}s",
show_results(scene, matches) }
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__": if __name__ == "__main__":
+275 -55
View File
@@ -26,6 +26,8 @@ della ROI (modello non-rettangolare).
from __future__ import annotations from __future__ import annotations
import os
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass from dataclasses import dataclass
import cv2 import cv2
@@ -34,6 +36,29 @@ import numpy as np
N_BINS = 8 # orientamenti quantizzati modulo π N_BINS = 8 # orientamenti quantizzati modulo π
def _oriented_bbox_polygon(
cx: float, cy: float, w: float, h: float, angle_deg: float,
) -> np.ndarray:
"""Ritorna 4 vertici (float32, shape (4,2)) del bbox orientato.
Convenzione coerente con cv2.getRotationMatrix2D usato nel train:
rotazione counter-clockwise (matematica) ma sistema immagine y-down,
quindi visivamente orario.
"""
w2, h2 = w / 2.0, h / 2.0
# Vertici template non-ruotato centrati al centro
corners = np.array([[-w2, -h2], [w2, -h2], [w2, h2], [-w2, h2]], np.float32)
a = np.deg2rad(angle_deg)
c, s = np.cos(a), np.sin(a)
# cv2.getRotationMatrix2D con angolo a positivo applica R = [[c,s],[-s,c]]
# (ruota counter-clockwise nel sistema matematico; y-down → orario)
R = np.array([[c, s], [-s, c]], np.float32)
rotated = corners @ R.T
rotated[:, 0] += cx
rotated[:, 1] += cy
return rotated
@dataclass @dataclass
class Match: class Match:
cx: float cx: float
@@ -41,7 +66,16 @@ class Match:
angle_deg: float angle_deg: float
scale: float scale: float
score: float score: float
bbox: tuple[int, int, int, int] bbox_poly: np.ndarray # (4, 2) float32 - 4 vertici ordinati (ruotato)
@dataclass
class _LevelFeatures:
"""Feature piramidate (livello l = downsample /2^l)."""
dx: np.ndarray # int32
dy: np.ndarray # int32
bin: np.ndarray # int8
n: int
@dataclass @dataclass
@@ -49,16 +83,13 @@ class _Variant:
"""Template precomputato (una pose).""" """Template precomputato (una pose)."""
angle_deg: float angle_deg: float
scale: float scale: float
# Feature come 3 array paralleli (dx, dy, bin) relativi al centro-modello # Feature piramide: levels[0] = full-res, levels[l] = /2^l
dx: np.ndarray # int32, shape (N,) levels: list[_LevelFeatures]
dy: np.ndarray # int32, shape (N,)
bin: np.ndarray # int8, shape (N,)
# Bbox kernel (per visualizzazione / limiti ricerca) # Bbox kernel (per visualizzazione / limiti ricerca)
kh: int kh: int
kw: int kw: int
cx_local: float # centro-modello dentro al bbox kernel (solo per bbox visivo) cx_local: float # centro-modello dentro al bbox kernel
cy_local: float cy_local: float
n_features: int
class LineShapeMatcher: class LineShapeMatcher:
@@ -77,6 +108,7 @@ class LineShapeMatcher:
min_feature_spacing: int = 3, min_feature_spacing: int = 3,
pyramid_levels: int = 2, pyramid_levels: int = 2,
top_score_factor: float = 0.5, top_score_factor: float = 0.5,
n_threads: int | None = None,
) -> None: ) -> None:
self.num_features = num_features self.num_features = num_features
self.weak_grad = weak_grad self.weak_grad = weak_grad
@@ -89,9 +121,11 @@ class LineShapeMatcher:
self.min_feature_spacing = min_feature_spacing self.min_feature_spacing = min_feature_spacing
self.pyramid_levels = max(1, pyramid_levels) self.pyramid_levels = max(1, pyramid_levels)
self.top_score_factor = top_score_factor self.top_score_factor = top_score_factor
self.n_threads = n_threads or max(1, (os.cpu_count() or 2) - 1)
self.variants: list[_Variant] = [] self.variants: list[_Variant] = []
self.template_size: tuple[int, int] = (0, 0) self.template_size: tuple[int, int] = (0, 0)
self.template_gray: np.ndarray | None = None
# --- Helpers ------------------------------------------------------- # --- Helpers -------------------------------------------------------
@@ -159,15 +193,32 @@ class LineShapeMatcher:
# --- Training ------------------------------------------------------ # --- Training ------------------------------------------------------
def train(self, template_bgr: np.ndarray, mask: np.ndarray | None = None) -> int: def _build_pyramid_features(
"""Genera varianti rotate+scalate con feature sparse. self, dx: np.ndarray, dy: np.ndarray, bin_: np.ndarray,
) -> list[_LevelFeatures]:
"""Piramide feature precomputata: livello l = /2^l con dedup."""
levels = [_LevelFeatures(dx=dx.copy(), dy=dy.copy(), bin=bin_.copy(),
n=len(dx))]
for lvl in range(1, self.pyramid_levels):
sf = 2 ** lvl
dx_l = (dx // sf).astype(np.int32)
dy_l = (dy // sf).astype(np.int32)
# Dedup: rimuove feature collassate sullo stesso (dx, dy, bin)
key = ((dx_l.astype(np.int64) << 24)
| (dy_l.astype(np.int64) << 8)
| bin_.astype(np.int64))
_, uniq = np.unique(key, return_index=True)
levels.append(_LevelFeatures(
dx=dx_l[uniq], dy=dy_l[uniq], bin=bin_[uniq], n=len(uniq),
))
return levels
mask: maschera binaria opzionale (stessa shape del template) per def train(self, template_bgr: np.ndarray, mask: np.ndarray | None = None) -> int:
limitare il modello a una regione non rettangolare. """Genera varianti rotate+scalate con feature sparse + piramide."""
"""
gray = self._to_gray(template_bgr) gray = self._to_gray(template_bgr)
h, w = gray.shape h, w = gray.shape
self.template_size = (w, h) self.template_size = (w, h)
self.template_gray = gray.copy()
if mask is None: if mask is None:
mask_full = np.full((h, w), 255, dtype=np.uint8) mask_full = np.full((h, w), 255, dtype=np.uint8)
else: else:
@@ -207,27 +258,26 @@ class LineShapeMatcher:
if len(fx) < 8: if len(fx) < 8:
continue continue
# Feature relative al centro-modello (centro rotazione)
cx_c = diag / 2.0 cx_c = diag / 2.0
cy_c = diag / 2.0 cy_c = diag / 2.0
dx = (fx - cx_c).astype(np.int32) dx = (fx - cx_c).astype(np.int32)
dy = (fy - cy_c).astype(np.int32) dy = (fy - cy_c).astype(np.int32)
# Dimensione bbox per visualizzazione
x0 = int(dx.min()); x1 = int(dx.max()) x0 = int(dx.min()); x1 = int(dx.max())
y0 = int(dy.min()); y1 = int(dy.max()) y0 = int(dy.min()); y1 = int(dy.max())
kw = x1 - x0 + 1 kw = x1 - x0 + 1
kh = y1 - y0 + 1 kh = y1 - y0 + 1
cx_local = -x0 # posizione centro dentro al bbox cx_local = -x0
cy_local = -y0 cy_local = -y0
levels = self._build_pyramid_features(dx, dy, fb)
self.variants.append(_Variant( self.variants.append(_Variant(
angle_deg=float(ang), angle_deg=float(ang),
scale=float(s), scale=float(s),
dx=dx, dy=dy, bin=fb, levels=levels,
kh=kh, kw=kw, kh=kh, kw=kw,
cx_local=float(cx_local), cy_local=float(cy_local), cx_local=float(cx_local), cy_local=float(cy_local),
n_features=len(fx),
)) ))
return len(self.variants) return len(self.variants)
@@ -249,16 +299,21 @@ class LineShapeMatcher:
@staticmethod @staticmethod
def _score_by_shift( def _score_by_shift(
resp: np.ndarray, dx: np.ndarray, dy: np.ndarray, bins: np.ndarray, resp: np.ndarray, dx: np.ndarray, dy: np.ndarray, bins: np.ndarray,
bin_has_data: np.ndarray | None = None,
) -> np.ndarray: ) -> np.ndarray:
"""score[y,x] = Σ_i resp[bin_i][y+dy_i, x+dx_i] / len(dx). """score[y,x] = Σ_i resp[bin_i][y+dy_i, x+dx_i] / len(dx).
Implementazione vettorizzata con slicing. Ottimizzazione: se `bin_has_data` è fornito, skippa feature il cui
bin non ha pixel attivi nella scena (contribuzione = 0).
""" """
_, H, W = resp.shape _, H, W = resp.shape
acc = np.zeros((H, W), dtype=np.float32) acc = np.zeros((H, W), dtype=np.float32)
for i in range(len(dx)): n = len(dx)
ddx = int(dx[i]); ddy = int(dy[i]); b = int(bins[i]) for i in range(n):
# dst[y, x] += resp[b][y+ddy, x+ddx] b = int(bins[i])
if bin_has_data is not None and not bin_has_data[b]:
continue
ddx = int(dx[i]); ddy = int(dy[i])
y0s = max(0, -ddy); y1s = min(H, H - ddy) y0s = max(0, -ddy); y1s = min(H, H - ddy)
x0s = max(0, -ddx); x1s = min(W, W - ddx) x0s = max(0, -ddx); x1s = min(W, W - ddx)
if y0s >= y1s or x0s >= x1s: if y0s >= y1s or x0s >= x1s:
@@ -266,16 +321,133 @@ class LineShapeMatcher:
y0r = y0s + ddy; y1r = y1s + ddy y0r = y0s + ddy; y1r = y1s + ddy
x0r = x0s + ddx; x1r = x1s + ddx x0r = x0s + ddx; x1r = x1s + ddx
acc[y0s:y1s, x0s:x1s] += resp[b, y0r:y1r, x0r:x1r] acc[y0s:y1s, x0s:x1s] += resp[b, y0r:y1r, x0r:x1r]
if len(dx) > 0: if n > 0:
acc /= len(dx) acc /= n
return acc return acc
@staticmethod
def _subpixel_peak(acc: np.ndarray, x: int, y: int) -> tuple[float, float]:
"""Fit parabolico 2D attorno al picco per offset subpixel (±0.5 px)."""
H, W = acc.shape
if x <= 0 or x >= W - 1 or y <= 0 or y >= H - 1:
return float(x), float(y)
c = acc[y, x]
dx2 = acc[y, x + 1] - 2 * c + acc[y, x - 1]
dy2 = acc[y + 1, x] - 2 * c + acc[y - 1, x]
dx1 = (acc[y, x + 1] - acc[y, x - 1]) / 2.0
dy1 = (acc[y + 1, x] - acc[y - 1, x]) / 2.0
ox = -dx1 / dx2 if abs(dx2) > 1e-6 else 0.0
oy = -dy1 / dy2 if abs(dy2) > 1e-6 else 0.0
ox = float(np.clip(ox, -0.5, 0.5))
oy = float(np.clip(oy, -0.5, 0.5))
return x + ox, y + oy
def _refine_angle(
self,
resp0: np.ndarray,
template_gray: np.ndarray,
cx: float, cy: float,
angle_deg: float, scale: float,
mask_full: np.ndarray,
angle_fine_step: float = 0.5,
search_radius: float | None = None,
) -> tuple[float, float, float, float]:
"""Ricerca angolare fine (sub-step) attorno al match grezzo.
Genera 5 template temporanei a angle ± {0.5, 1.0} * step e sceglie
l'angolo con score massimo (parabolic fit sulle 3 score centrali).
Ritorna (angle_refined, score, cx_refined, cy_refined).
"""
if search_radius is None:
search_radius = self.angle_step_deg / 2.0
offsets = np.linspace(-search_radius, search_radius, 5)
best = (angle_deg, -1.0, cx, cy)
scores_by_off: dict[float, float] = {}
h, w = template_gray.shape
sw = max(16, int(round(w * scale)))
sh = max(16, int(round(h * scale)))
gray_s = cv2.resize(template_gray, (sw, sh), interpolation=cv2.INTER_LINEAR)
mask_s = cv2.resize(mask_full, (sw, sh), interpolation=cv2.INTER_NEAREST)
diag = int(np.ceil(np.hypot(sh, sw))) + 6
py = (diag - sh) // 2; px = (diag - sw) // 2
gray_p = cv2.copyMakeBorder(gray_s, py, diag - sh - py, px, diag - sw - px,
cv2.BORDER_REPLICATE)
mask_p = cv2.copyMakeBorder(mask_s, py, diag - sh - py, px, diag - sw - px,
cv2.BORDER_CONSTANT, value=0)
center = (diag / 2.0, diag / 2.0)
H, W = resp0.shape[1], resp0.shape[2]
# Ricerca locale posizione con margine ±2 px sulla (cx, cy)
margin = 3
for off in offsets:
ang = angle_deg + off
M = cv2.getRotationMatrix2D(center, ang, 1.0)
gray_r = cv2.warpAffine(gray_p, M, (diag, diag),
flags=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_REPLICATE)
mask_r = cv2.warpAffine(mask_p, M, (diag, diag),
flags=cv2.INTER_NEAREST, borderValue=0)
mag, bins = self._gradient(gray_r)
fx, fy, fb = self._extract_features(mag, bins, mask_r)
if len(fx) < 8:
scores_by_off[float(off)] = 0.0
continue
dx = (fx - center[0]).astype(np.int32)
dy = (fy - center[1]).astype(np.int32)
# Finestra locale ±margin attorno a (cx, cy) via slicing vettorizzato
y_lo = int(cy) - margin; y_hi = int(cy) + margin + 1
x_lo = int(cx) - margin; x_hi = int(cx) + margin + 1
sh = y_hi - y_lo; sw = x_hi - x_lo
acc = np.zeros((sh, sw), dtype=np.float32)
for i in range(len(dx)):
ddx = int(dx[i]); ddy = int(dy[i]); b = int(fb[i])
sy0 = y_lo + ddy; sy1 = y_hi + ddy
sx0 = x_lo + ddx; sx1 = x_hi + ddx
a_y0 = max(0, -sy0); a_y1 = sh - max(0, sy1 - H)
a_x0 = max(0, -sx0); a_x1 = sw - max(0, sx1 - W)
s_y0 = max(0, sy0); s_y1 = min(H, sy1)
s_x0 = max(0, sx0); s_x1 = min(W, sx1)
if s_y1 > s_y0 and s_x1 > s_x0:
acc[a_y0:a_y1, a_x0:a_x1] += resp0[b, s_y0:s_y1, s_x0:s_x1]
acc /= len(dx)
_, max_val, _, max_loc = cv2.minMaxLoc(acc)
scores_by_off[float(off)] = float(max_val)
if max_val > best[1]:
new_cx = x_lo + float(max_loc[0])
new_cy = y_lo + float(max_loc[1])
best = (ang, float(max_val), new_cx, new_cy)
# Parabolic fit su 3 angoli attorno al massimo
sorted_offs = sorted(scores_by_off.keys())
best_off = best[0] - angle_deg
try:
i = sorted_offs.index(
min(sorted_offs, key=lambda x: abs(x - best_off))
)
if 0 < i < len(sorted_offs) - 1:
s0 = scores_by_off[sorted_offs[i - 1]]
s1 = scores_by_off[sorted_offs[i]]
s2 = scores_by_off[sorted_offs[i + 1]]
denom = (s0 - 2 * s1 + s2)
if abs(denom) > 1e-6:
delta = 0.5 * (s0 - s2) / denom
step = sorted_offs[i + 1] - sorted_offs[i]
refined_off = sorted_offs[i] + delta * step
return (angle_deg + refined_off, best[1], best[2], best[3])
except ValueError:
pass
return best
def find( def find(
self, self,
scene_bgr: np.ndarray, scene_bgr: np.ndarray,
min_score: float = 0.6, min_score: float = 0.6,
max_matches: int = 20, max_matches: int = 20,
nms_radius: int | None = None, nms_radius: int | None = None,
refine_angle: bool = True,
subpixel: bool = True,
) -> list[Match]: ) -> list[Match]:
if not self.variants: if not self.variants:
raise RuntimeError("Matcher non addestrato: chiamare train() prima.") raise RuntimeError("Matcher non addestrato: chiamare train() prima.")
@@ -285,66 +457,114 @@ class LineShapeMatcher:
for _ in range(self.pyramid_levels - 1): for _ in range(self.pyramid_levels - 1):
grays.append(cv2.pyrDown(grays[-1])) grays.append(cv2.pyrDown(grays[-1]))
top = len(grays) - 1 top = len(grays) - 1
sf = 2 ** top
# Response map top-level (usata SOLO per pruning varianti) # Response map top-level
resp_top = self._response_map(grays[top]) resp_top = self._response_map(grays[top])
bin_has_top = np.array([resp_top[b].any() for b in range(N_BINS)])
if nms_radius is None: if nms_radius is None:
nms_radius = max(8, min(self.template_size) // 2) nms_radius = max(8, min(self.template_size) // 2)
top_thresh = min_score * self.top_score_factor top_thresh = min_score * self.top_score_factor
# Pruning varianti via top-level # Pruning varianti via top-level (parallelizzato)
kept_variants: list[int] = [] def _top_score(vi: int) -> tuple[int, float]:
for vi, var in enumerate(self.variants): var = self.variants[vi]
dx_t = (var.dx // sf).astype(np.int32) lvl = var.levels[min(top, len(var.levels) - 1)]
dy_t = (var.dy // sf).astype(np.int32)
key = ((dx_t.astype(np.int64) << 24)
| (dy_t.astype(np.int64) << 8)
| var.bin.astype(np.int64))
_, uniq_idx = np.unique(key, return_index=True)
score = self._score_by_shift( score = self._score_by_shift(
resp_top, dx_t[uniq_idx], dy_t[uniq_idx], var.bin[uniq_idx], resp_top, lvl.dx, lvl.dy, lvl.bin, bin_has_data=bin_has_top,
) )
if score.size and score.max() >= top_thresh: return vi, float(score.max()) if score.size else -1.0
kept_variants.append(vi)
kept_variants: list[tuple[int, float]] = []
if self.n_threads > 1:
with ThreadPoolExecutor(max_workers=self.n_threads) as ex:
for vi, best in ex.map(_top_score, range(len(self.variants))):
if best >= top_thresh:
kept_variants.append((vi, best))
else:
for vi in range(len(self.variants)):
vi2, best = _top_score(vi)
if best >= top_thresh:
kept_variants.append((vi2, best))
if not kept_variants: if not kept_variants:
return [] return []
# Full-res: score_by_shift solo per le varianti sopravvissute max_vars_full = max(8, max_matches * 4)
kept_variants.sort(key=lambda t: -t[1])
kept_variants = kept_variants[:max_vars_full]
# Full-res (parallelizzato per variante)
resp0 = self._response_map(gray0) resp0 = self._response_map(gray0)
refined: list[tuple[float, float, float, int]] = [] bin_has_full = np.array([resp0[b].any() for b in range(N_BINS)])
for vi in kept_variants:
def _full_score(vi: int) -> tuple[int, np.ndarray]:
var = self.variants[vi] var = self.variants[vi]
score = self._score_by_shift(resp0, var.dx, var.dy, var.bin) lvl0 = var.levels[0]
# Picchi sopra soglia score = self._score_by_shift(
resp0, lvl0.dx, lvl0.dy, lvl0.bin, bin_has_data=bin_has_full,
)
return vi, score
candidates_per_var: list[tuple[int, np.ndarray]] = []
raw: list[tuple[float, int, int, int]] = []
var_indices = [vi for vi, _ in kept_variants]
if self.n_threads > 1 and len(var_indices) > 1:
with ThreadPoolExecutor(max_workers=self.n_threads) as ex:
results = list(ex.map(_full_score, var_indices))
else:
results = [_full_score(vi) for vi in var_indices]
for vi, score in results:
ys, xs = np.where(score >= min_score) ys, xs = np.where(score >= min_score)
if len(ys) == 0: if len(ys) == 0:
continue continue
vals = score[ys, xs] vals = score[ys, xs]
# Ordine decrescente (solo i top-K per evitare liste enormi)
K = min(len(vals), max_matches * 5) K = min(len(vals), max_matches * 5)
ord_idx = np.argpartition(-vals, K - 1)[:K] ord_idx = np.argpartition(-vals, K - 1)[:K]
candidates_per_var.append((vi, score))
for i in ord_idx: for i in ord_idx:
refined.append((float(vals[i]), raw.append((float(vals[i]), int(xs[i]), int(ys[i]), vi))
float(xs[i]), float(ys[i]), vi))
refined.sort(key=lambda c: -c[0]) raw.sort(key=lambda c: -c[0])
# Mappa vi → score_map per subpixel/refinement
score_maps = dict(candidates_per_var)
# NMS + subpixel + refinement angolare
# Mask template per refinement (non disponibile qui: usa full)
h, w = self.template_gray.shape if self.template_gray is not None else (0, 0)
mask_full = np.full((h, w), 255, dtype=np.uint8)
kept: list[Match] = [] kept: list[Match] = []
r2 = nms_radius * nms_radius r2 = nms_radius * nms_radius
for score, cx, cy, vi in refined: tw, th = self.template_size
if any((k.cx - cx) ** 2 + (k.cy - cy) ** 2 < r2 for k in kept): for score, xi, yi, vi in raw:
continue
var = self.variants[vi] var = self.variants[vi]
bx = int(round(cx - var.cx_local)) cx_f = float(xi); cy_f = float(yi)
by = int(round(cy - var.cy_local)) if subpixel and vi in score_maps:
cx_f, cy_f = self._subpixel_peak(score_maps[vi], xi, yi)
if any((k.cx - cx_f) ** 2 + (k.cy - cy_f) ** 2 < r2 for k in kept):
continue
ang_f = var.angle_deg
score_f = score
if refine_angle and self.template_gray is not None:
ang_f, score_f, cx_f, cy_f = self._refine_angle(
resp0, self.template_gray, cx_f, cy_f,
var.angle_deg, var.scale, mask_full,
search_radius=self.angle_step_deg / 2.0,
)
poly = _oriented_bbox_polygon(
cx_f, cy_f, tw * var.scale, th * var.scale, ang_f,
)
kept.append(Match( kept.append(Match(
cx=cx, cy=cy, cx=cx_f, cy=cy_f,
angle_deg=var.angle_deg, angle_deg=ang_f,
scale=var.scale, scale=var.scale,
score=score, score=score_f,
bbox=(bx, by, var.kw, var.kh), bbox_poly=poly,
)) ))
if len(kept) >= max_matches: if len(kept) >= max_matches:
break break
+10 -3
View File
@@ -16,6 +16,8 @@ from dataclasses import dataclass
import cv2 import cv2
import numpy as np import numpy as np
from pm2d.line_matcher import _oriented_bbox_polygon
@dataclass @dataclass
class Match: class Match:
@@ -26,7 +28,7 @@ class Match:
angle_deg: float # rotazione [0, 360) angle_deg: float # rotazione [0, 360)
scale: float # fattore scala (1.0 = template originale) scale: float # fattore scala (1.0 = template originale)
score: float # similarità NCC [0, 1] score: float # similarità NCC [0, 1]
bbox: tuple[int, int, int, int] # x, y, w, h del template ruotato/scalato bbox_poly: np.ndarray # (4, 2) float32 - vertici bbox orientato
@dataclass @dataclass
@@ -67,6 +69,7 @@ class EdgeShapeMatcher:
self.top_score_factor = top_score_factor self.top_score_factor = top_score_factor
self.templates: list[Template] = [] self.templates: list[Template] = []
self.template_size: tuple[int, int] = (0, 0) # w, h originale self.template_size: tuple[int, int] = (0, 0) # w, h originale
self.template_gray: np.ndarray | None = None
@staticmethod @staticmethod
def _to_gray(img: np.ndarray) -> np.ndarray: def _to_gray(img: np.ndarray) -> np.ndarray:
@@ -96,6 +99,7 @@ class EdgeShapeMatcher:
gray = self._to_gray(template_bgr) gray = self._to_gray(template_bgr)
h, w = gray.shape h, w = gray.shape
self.template_size = (w, h) self.template_size = (w, h)
self.template_gray = gray.copy()
edge_orig = self._edges(gray) edge_orig = self._edges(gray)
mask_orig = np.full((h, w), 255, dtype=np.uint8) mask_orig = np.full((h, w), 255, dtype=np.uint8)
@@ -249,20 +253,23 @@ class EdgeShapeMatcher:
# NMS spaziale baricentri # NMS spaziale baricentri
kept: list[Match] = [] kept: list[Match] = []
r2 = nms_radius * nms_radius r2 = nms_radius * nms_radius
tw0, th0 = self.template_size
for score, x, y, ti in refined: for score, x, y, ti in refined:
tpl = self.templates[ti] tpl = self.templates[ti]
cx = x + tpl.cx_local cx = x + tpl.cx_local
cy = y + tpl.cy_local cy = y + tpl.cy_local
if any((k.cx - cx) ** 2 + (k.cy - cy) ** 2 < r2 for k in kept): if any((k.cx - cx) ** 2 + (k.cy - cy) ** 2 < r2 for k in kept):
continue continue
th, tw = tpl.edge.shape poly = _oriented_bbox_polygon(
cx, cy, tw0 * tpl.scale, th0 * tpl.scale, tpl.angle_deg,
)
kept.append( kept.append(
Match( Match(
cx=cx, cy=cy, cx=cx, cy=cy,
angle_deg=tpl.angle_deg, angle_deg=tpl.angle_deg,
scale=tpl.scale, scale=tpl.scale,
score=score, score=score,
bbox=(x, y, tw, th), bbox_poly=poly,
) )
) )
if len(kept) >= max_matches: if len(kept) >= max_matches: