feat: PM2D standalone shape-based matcher
Programma standalone Pattern Matching 2D con GUI cv2/tk + algoritmo puro riusabile. Due backend: - LineShapeMatcher (default): porting Python di line2Dup (linemod-style) - Gradient orientation quantized 8-bin modulo π + spreading - Feature sparse top-magnitude con spacing minimo - Score via shift-add vettorizzato numpy (O(N_features·H·W)) - Piramide multi-risoluzione con pruning varianti al top-level - Supporto mask binaria per modello non-rettangolare - EdgeShapeMatcher (fallback): Canny + matchTemplate multi-rotazione GUI separata da algoritmo. Benchmark clip.png (13 istanze): - Edge backend: 84s, 6/13 score ~0.3 - Line backend: 4.1s, 13/13 score 0.98-1.00 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+195
@@ -0,0 +1,195 @@
|
||||
"""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 cv2
|
||||
import numpy as np
|
||||
|
||||
from pm2d.matcher import EdgeShapeMatcher
|
||||
from pm2d.line_matcher import LineShapeMatcher, Match
|
||||
|
||||
|
||||
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 draw_matches(scene: np.ndarray, matches: list[Match]) -> np.ndarray:
|
||||
"""Disegna baricentro, asse orientamento, bbox ruotato per ogni match."""
|
||||
out = scene.copy()
|
||||
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)
|
||||
# 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)
|
||||
# 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)
|
||||
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]) -> None:
|
||||
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)
|
||||
cv2.imshow(WINDOW_RESULT, disp)
|
||||
print("\nPremere un tasto sulla finestra per chiudere.")
|
||||
cv2.waitKey(0)
|
||||
cv2.destroyAllWindows()
|
||||
|
||||
|
||||
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,
|
||||
backend: str = "line",
|
||||
) -> None:
|
||||
"""Entry-point GUI completo."""
|
||||
print("[1/4] 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/4] 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/4] 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"[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,
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user