diff --git a/pm2d/auto_tune.py b/pm2d/auto_tune.py index 4c715f3..f62f96e 100644 --- a/pm2d/auto_tune.py +++ b/pm2d/auto_tune.py @@ -152,11 +152,103 @@ def _cache_key(template_bgr: np.ndarray, mask: np.ndarray | None) -> str: return h.hexdigest() +def _self_validate(template_bgr: np.ndarray, params: dict, + mask: np.ndarray | None = None) -> dict: + """Halcon-style self-validation: train il matcher coi parametri tentativi + e verifica che il template stesso sia trovato con recall ≥ 1.0. + + Se recall < target o score basso, regola i parametri: + - alza weak_grad se troppi edge spuri (recall solido ma molti picchi falsi) + - abbassa strong_grad se troppe feature scartate (low feature count) + - riduce pyramid_levels se variants[0].levels[top] ha <8 feature + + Halcon usa internamente questo loop in inspect_shape_model. Costo: 1 + train + 1 find sul template (~50ms su template 100x100). Ne vale la + pena se evita match-time errors su scene reali. + + Mutates `params` in place e ritorna lo stesso dict per chaining. + """ + # Import lazy: evita ciclo (line_matcher importa nulla da auto_tune) + from pm2d.line_matcher import LineShapeMatcher + + # Caso degenerato: troppe poche feature pre-validation → riduci soglia + if params.get("_n_strong_pixels", 0) < 30: + params["weak_grad"] = max(15.0, params["weak_grad"] * 0.6) + params["strong_grad"] = max(30.0, params["strong_grad"] * 0.6) + + # Train minimale: 1 sola pose orientazione 0 (range degenerato che + # produce comunque 1 variante via fallback in _angle_list). + m = LineShapeMatcher( + num_features=params["num_features"], + weak_grad=params["weak_grad"], + strong_grad=params["strong_grad"], + angle_range_deg=(0.0, 0.0), # fallback _angle_list = [0.0] + angle_step_deg=10.0, + scale_range=(1.0, 1.0), + spread_radius=params["spread_radius"], + pyramid_levels=params["pyramid_levels"], + ) + n_var = m.train(template_bgr, mask=mask) + if n_var == 0: + # Soglie troppo alte: nessuna variante generata → dimezza + params["weak_grad"] = max(15.0, params["weak_grad"] * 0.5) + params["strong_grad"] = max(30.0, params["strong_grad"] * 0.5) + params["_validation"] = "fallback: soglie dimezzate (no variants)" + return params + + # Verifica densita' feature al top-level (rischio collasso) + top_lvl = m.variants[0].levels[-1] + if top_lvl.n < 8 and params["pyramid_levels"] > 1: + params["pyramid_levels"] = max(1, params["pyramid_levels"] - 1) + params["_validation"] = ( + f"pyramid_levels ridotto a {params['pyramid_levels']} " + f"(top aveva {top_lvl.n} feature)" + ) + return params + + # Self-find: cerca il template stesso nella propria immagine + h, w = template_bgr.shape[:2] + # Embed template in scena leggermente più grande per evitare bordo + pad = 20 + canvas = np.full( + (h + 2 * pad, w + 2 * pad, 3 if template_bgr.ndim == 3 else 1), + 128, dtype=np.uint8, + ) + canvas[pad:pad + h, pad:pad + w] = template_bgr + matches = m.find( + canvas, min_score=0.3, max_matches=5, + verify_ncc=False, # template stesso → NCC = 1 sempre, skip per velocita' + refine_angle=False, subpixel=False, + nms_iou_threshold=0.3, + ) + if not matches: + # Nessun match sul proprio template: parametri troppo restrittivi + params["weak_grad"] = max(15.0, params["weak_grad"] * 0.7) + params["strong_grad"] = max(30.0, params["strong_grad"] * 0.7) + params["num_features"] = max(48, int(params["num_features"] * 0.8)) + params["_validation"] = "soglie/feature ridotte (no self-match)" + return params + + # Misura score top match + top_score = float(matches[0].score) + params["_self_score"] = round(top_score, 3) + if top_score < 0.7: + # Score basso sul template stesso = parametri davvero subottimali + params["weak_grad"] = max(15.0, params["weak_grad"] * 0.85) + params["_validation"] = ( + f"weak_grad ridotto (self-score era {top_score:.2f})" + ) + else: + params["_validation"] = f"OK (self-score {top_score:.2f})" + return params + + def auto_tune( template_bgr: np.ndarray, mask: np.ndarray | None = None, angle_tolerance_deg: float | None = None, angle_center_deg: float = 0.0, + self_validate: bool = True, ) -> dict: """Analizza template e ritorna dict parametri suggeriti. @@ -168,6 +260,11 @@ def auto_tune( meccanico): training molto piu rapido (24x meno varianti per tol=15° vs 360° pieno). + self_validate: se True (default), dopo la stima dei parametri + esegue un dry-run del matching sul template stesso e regola + weak_grad/strong_grad/pyramid_levels se i parametri tentativi + non garantiscono auto-match (Halcon-style inspect_shape_model). + Risultato cachato in-memory (LRU): ri-chiamare con stessa ROI è O(1). """ ck = _cache_key(template_bgr, mask) @@ -265,7 +362,15 @@ def auto_tune( "_symmetry_order": sym["order"], "_symmetry_conf": round(sym["confidence"], 2), "_orient_entropy": round(stats["orient_entropy"], 2), + "_n_strong_pixels": stats["n_strong"], } + # Halcon-style self-validation: dry-run training+find sul template per + # auto-correggere parametri tentativi che non garantirebbero match. + if self_validate: + result = _self_validate(template_bgr, result, mask=mask) + # Round numerici dopo eventuali aggiustamenti + result["weak_grad"] = round(result["weak_grad"], 1) + result["strong_grad"] = round(result["strong_grad"], 1) # Store in LRU cache _TUNE_CACHE[ck] = dict(result) _TUNE_CACHE.move_to_end(ck)