Files
Shape_Model_2D/pm2d/auto_tune.py
T
Adriano 041b26e791 feat: helper set_angle_range_around + angle_tolerance hint in auto_tune
LineShapeMatcher.set_angle_range_around(center, tol): restringe
angle_range a (center-tol, center+tol). Use case: feeder/posizionamento
meccanico noto a priori. Esempio:
    m.set_angle_range_around(0, 20)  # cerca solo in [-20, +20]

auto_tune accetta angle_tolerance_deg + angle_center_deg: emette
angle_min/angle_max ristretti se hint fornito. Cache key include
hint per non collidere con tune default.

Beneficio misurato: angle_step=5 deg, template 80x80
- range 360°: 72 varianti
- range ±15°: 6 varianti (12x meno = matching ~12x piu veloce)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:08:56 +02:00

289 lines
10 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 hashlib
from collections import OrderedDict
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
# Cache in-memory (LRU) dei risultati auto_tune per stesso input ROI.
_TUNE_CACHE: OrderedDict[str, dict] = OrderedDict()
_TUNE_CACHE_SIZE = 32
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).
Downsample a max 128 px prima di correlare per abbattere il costo
O(n_angles · H · W) senza perdere precisione (la simmetria rotazionale
è invariante a subsampling moderato).
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
target = 128
if max(h, w) > target:
sf = target / max(h, w)
new_w = max(32, int(w * sf))
new_h = max(32, int(h * sf))
gray = cv2.resize(gray, (new_w, new_h), interpolation=cv2.INTER_AREA)
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: p55/p85 usati per soglie weak/strong (più aderenti
# alla distribuzione reale rispetto a p50/p80 + clamp).
p50 = float(np.percentile(mag, 50))
p55 = float(np.percentile(mag, 55))
p80 = float(np.percentile(mag, 80))
p85 = float(np.percentile(mag, 85))
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, "p55": p55, "p80": p80, "p85": p85, "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 _cache_key(template_bgr: np.ndarray, mask: np.ndarray | None) -> str:
h = hashlib.md5()
h.update(np.ascontiguousarray(template_bgr).tobytes())
h.update(f"shape={template_bgr.shape}".encode())
if mask is not None:
h.update(np.ascontiguousarray(mask).tobytes())
return h.hexdigest()
def auto_tune(
template_bgr: np.ndarray,
mask: np.ndarray | None = None,
angle_tolerance_deg: float | None = None,
angle_center_deg: float = 0.0,
) -> dict:
"""Analizza template e ritorna dict parametri suggeriti.
Chiavi compatibili con edit_params PARAM_SCHEMA.
angle_tolerance_deg: se != None, restringe angle_range a
(center - tol, center + tol). Usare quando l'orientamento del
pezzo e' noto a priori (feeder con guida, posizionamento
meccanico): training molto piu rapido (24x meno varianti per
tol=15° vs 360° pieno).
Risultato cachato in-memory (LRU): ri-chiamare con stessa ROI è O(1).
"""
ck = _cache_key(template_bgr, mask)
if angle_tolerance_deg is not None:
ck = f"{ck}|tol={angle_tolerance_deg}|c={angle_center_deg}"
cached = _TUNE_CACHE.get(ck)
if cached is not None:
_TUNE_CACHE.move_to_end(ck)
return dict(cached)
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 reali (p85/p55) senza clamp duro a 100.
# Sobel ksize=3 su uint8 può arrivare a ~1020, quindi clamp massimo 400
# evita saturazione del threshold su template ad alto contrasto.
strong_grad = float(np.clip(stats["p85"], 30.0, 400.0))
weak_grad = float(np.clip(stats["p55"], 15.0, strong_grad * 0.7))
# num_features: ibrido perimetro + densità. Target = min(perimeter_budget,
# density_budget) per non generare più feature di quante edge nitide siano
# disponibili, ma neanche meno di quante il perimetro possa tracciare.
perim_budget = int(2 * (h + w) * 0.4) # ~40% dei pixel di perimetro
density_budget = int(stats["n_strong"] / 20) # 1 feature ogni ~20 px forti
target_feat = int(np.clip(min(perim_budget, density_budget), 64, 192))
# pyramid_levels in base a dimensione minima E densità feature: un template
# grande ma povero di feature non deve scendere troppi livelli (rischio
# collasso a <16 feature al top level).
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
# Cap: non scendere sotto ~16 feature al top level (feature ÷ 4^(pyr-1))
max_pyr_from_feat = max(1, int(np.floor(np.log2(max(1, target_feat / 16.0)) / 2.0)) + 1)
pyr = min(pyr, max_pyr_from_feat)
# spread_radius proporzionale a risoluzione + pyramid (tolleranza ~1% dim)
spread_radius = int(np.clip(max(3, min_side * 0.02), 3, 8))
# angle range: priorita' a tolerance hint utente, poi simmetria rotazionale.
if angle_tolerance_deg is not None:
angle_min = float(angle_center_deg - angle_tolerance_deg)
angle_max = float(angle_center_deg + angle_tolerance_deg)
else:
angle_min = 0.0
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 adattivo (Halcon-style): atan(2/max_side) deg, clampato.
# Template grande → step fine (rotazione minima visibile su perimetro).
# Template piccolo → step grosso (over-sampling = sprecato).
max_side = max(h, w)
angle_step = float(np.clip(np.degrees(np.arctan2(2.0, max_side)), 1.0, 8.0))
result = {
"backend": "line",
"angle_min": angle_min,
"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,
"verify_threshold": 0.4,
# 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),
}
# Store in LRU cache
_TUNE_CACHE[ck] = dict(result)
_TUNE_CACHE.move_to_end(ck)
while len(_TUNE_CACHE) > _TUNE_CACHE_SIZE:
_TUNE_CACHE.popitem(last=False)
return result
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']}"
)