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
+10 -3
View File
@@ -16,6 +16,8 @@ from dataclasses import dataclass
import cv2
import numpy as np
from pm2d.line_matcher import _oriented_bbox_polygon
@dataclass
class Match:
@@ -26,7 +28,7 @@ class Match:
angle_deg: float # rotazione [0, 360)
scale: float # fattore scala (1.0 = template originale)
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
@@ -67,6 +69,7 @@ class EdgeShapeMatcher:
self.top_score_factor = top_score_factor
self.templates: list[Template] = []
self.template_size: tuple[int, int] = (0, 0) # w, h originale
self.template_gray: np.ndarray | None = None
@staticmethod
def _to_gray(img: np.ndarray) -> np.ndarray:
@@ -96,6 +99,7 @@ class EdgeShapeMatcher:
gray = self._to_gray(template_bgr)
h, w = gray.shape
self.template_size = (w, h)
self.template_gray = gray.copy()
edge_orig = self._edges(gray)
mask_orig = np.full((h, w), 255, dtype=np.uint8)
@@ -249,20 +253,23 @@ class EdgeShapeMatcher:
# NMS spaziale baricentri
kept: list[Match] = []
r2 = nms_radius * nms_radius
tw0, th0 = self.template_size
for score, x, y, ti in refined:
tpl = self.templates[ti]
cx = x + tpl.cx_local
cy = y + tpl.cy_local
if any((k.cx - cx) ** 2 + (k.cy - cy) ** 2 < r2 for k in kept):
continue
th, tw = tpl.edge.shape
poly = _oriented_bbox_polygon(
cx, cy, tw0 * tpl.scale, th0 * tpl.scale, tpl.angle_deg,
)
kept.append(
Match(
cx=cx, cy=cy,
angle_deg=tpl.angle_deg,
scale=tpl.scale,
score=score,
bbox=(x, y, tw, th),
bbox_poly=poly,
)
)
if len(kept) >= max_matches: