Files
Shape_Model_2D/pm2d/auto_tune.py
T
Adriano 075b014bd7 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>
2026-04-24 01:22:56 +02:00

212 lines
7.1 KiB
Python

"""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']}"
)