From dae49eb4a39565baa406ec8ae7c41007b0367fe6 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Tue, 5 May 2026 10:05:20 +0200 Subject: [PATCH] feat: diagnostic mode trasparente per find() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit self._last_diag accumula counter durante find(): - Pipeline pruning: top_evaluated, top_passed, full_evaluated - Candidati: n_raw, n_after_pre_nms, n_final - Drop reason: ncc_low, min_score_post_avg, recall_low, bbox_out_of_scene, nms_iou - Param effettivi: top_thresh_used, verify_threshold_used, ecc. API: - find(debug=True): stampa one-line summary su stderr - m.get_last_diag(): ritorna dict completo per inspection Use case: 0 match? guarda dove sono finiti i candidati (es. drop_ncc_low=200 → soglia NCC troppo alta) invece di tirare a caso. Risolve il "find black-box" pattern. Co-Authored-By: Claude Opus 4.7 (1M context) --- pm2d/line_matcher.py | 69 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/pm2d/line_matcher.py b/pm2d/line_matcher.py index 899a746..65d0361 100644 --- a/pm2d/line_matcher.py +++ b/pm2d/line_matcher.py @@ -1309,6 +1309,7 @@ class LineShapeMatcher: min_recall: float = 0.0, use_soft_score: bool = False, subpixel_lm: bool = False, + debug: bool = False, ) -> list[Match]: """ scale_penalty: se > 0, riduce lo score per match a scala diversa da 1.0: @@ -1326,6 +1327,32 @@ class LineShapeMatcher: if not self.variants: raise RuntimeError("Matcher non addestrato: chiamare train() prima.") + # Diagnostic counter: traccia perche' candidati sono droppati lungo + # la pipeline. Esposto via get_last_diag() o ritornato implicitamente + # se debug=True (vedi sotto). + diag = { + "n_variants_total": len(self.variants), + "n_variants_top_evaluated": 0, + "n_variants_top_passed": 0, + "n_variants_full_evaluated": 0, + "n_raw_candidates": 0, + "n_after_pre_nms": 0, + "drop_ncc_low": 0, + "drop_min_score_post_avg": 0, + "drop_recall_low": 0, + "drop_bbox_out_of_scene": 0, + "drop_nms_iou": 0, + "n_final": 0, + "top_thresh_used": 0.0, + "verify_threshold_used": float(verify_threshold), + "min_score_used": float(min_score), + "min_recall_used": float(min_recall), + "use_polarity": bool(self.use_polarity), + "use_soft_score": bool(use_soft_score), + "subpixel_lm": bool(subpixel_lm), + } + self._last_diag = diag + gray_full = self._to_gray(scene_bgr) # Applica ROI di ricerca: restringe scena a crop, ricorda offset per # ri-traslare le coordinate dei match a fine pipeline. @@ -1368,6 +1395,7 @@ class LineShapeMatcher: top_factor = max(top_factor, 0.7) cf_eff = 1 top_thresh = min_score * top_factor + diag["top_thresh_used"] = float(top_thresh) tw, th = self.template_size density_top = _jit_popcount(spread_top) @@ -1453,6 +1481,7 @@ class LineShapeMatcher: kept_coarse: list[tuple[int, float]] = [] all_top_scores: list[tuple[int, float]] = [] + diag["n_variants_top_evaluated"] = len(coarse_idx_list) # batch_top: usa kernel batch single-call con prange-esterno su # varianti. Vince su threadpool quando n_vars >> n_threads e quando # H*W top e' piccolo (overhead chiamate JIT > costo kernel). @@ -1516,6 +1545,8 @@ class LineShapeMatcher: kept_variants.sort(key=lambda t: -t[1]) max_vars_full = max(max_matches * 8, len(self.variants) // 2) kept_variants = kept_variants[:max_vars_full] + diag["n_variants_top_passed"] = len(kept_coarse) + diag["n_variants_full_evaluated"] = len(kept_variants) # Full-res (parallelizzato) con bitmap spread0 = self._spread_bitmap(gray0) @@ -1601,6 +1632,7 @@ class LineShapeMatcher: raw.append((float(vals[i]), int(xs[i]), int(ys[i]), vi)) raw.sort(key=lambda c: -c[0]) + diag["n_raw_candidates"] = len(raw) # Mappa vi → score_map per subpixel/refinement score_maps = dict(candidates_per_var) @@ -1632,6 +1664,7 @@ class LineShapeMatcher: preliminary_int.append((score, xi, yi, vi)) if len(preliminary_int) >= pre_cap: break + diag["n_after_pre_nms"] = len(preliminary_int) # Subpixel + refine + verify solo sui candidati pre-NMS (max pre_cap) kept: list[Match] = [] @@ -1678,6 +1711,7 @@ class LineShapeMatcher: view_idx=getattr(var, "view_idx", 0), ) if ncc < verify_threshold: + diag["drop_ncc_low"] += 1 continue score_f = (float(score_f) + max(0.0, ncc)) * 0.5 # Soft-margin gradient similarity: sostituisce o integra lo @@ -1692,6 +1726,7 @@ class LineShapeMatcher: # abbattere lo shape-score sotto la soglia user. Senza questo # check apparivano match con score < min_score (UI confusing). if float(score_f) < min_score: + diag["drop_min_score_post_avg"] += 1 continue # Feature recall (Halcon MinScore-style): conta quante feature @@ -1703,6 +1738,7 @@ class LineShapeMatcher: spread0, var, cx_f, cy_f, ang_f, ) if recall < min_recall: + diag["drop_recall_low"] += 1 continue # Ri-traslo coord da spazio crop ROI a spazio scena originale. @@ -1726,6 +1762,7 @@ class LineShapeMatcher: ) inside_ratio = float(inter) / poly_area if inside_ratio < 0.75: + diag["drop_bbox_out_of_scene"] += 1 continue # Penalità scala opzionale: score degrada con distanza da 1.0 if scale_penalty > 0.0 and var.scale != 1.0: @@ -1750,6 +1787,7 @@ class LineShapeMatcher: dup = True break if dup: + diag["drop_nms_iou"] += 1 continue kept.append(Match( cx=cx_out, cy=cy_out, @@ -1760,4 +1798,35 @@ class LineShapeMatcher: )) if len(kept) >= max_matches: break + diag["n_final"] = len(kept) + if debug: + # Debug mode: stampa diagnostica su stderr per visibilita' immediata. + import sys as _sys + _sys.stderr.write(f"[pm2d.find debug] {self._format_diag(diag)}\n") return kept + + def _format_diag(self, diag: dict) -> str: + """Formatta dict diagnostica in una linea leggibile.""" + return ( + f"vars: {diag['n_variants_total']} -> " + f"top_eval={diag['n_variants_top_evaluated']} " + f"top_pass={diag['n_variants_top_passed']} " + f"full_eval={diag['n_variants_full_evaluated']} | " + f"raw={diag['n_raw_candidates']} " + f"pre_nms={diag['n_after_pre_nms']} -> " + f"drop[ncc={diag['drop_ncc_low']}, " + f"score={diag['drop_min_score_post_avg']}, " + f"recall={diag['drop_recall_low']}, " + f"bbox={diag['drop_bbox_out_of_scene']}, " + f"nms={diag['drop_nms_iou']}] = " + f"final={diag['n_final']} (top_thresh={diag['top_thresh_used']:.2f})" + ) + + def get_last_diag(self) -> dict | None: + """Ritorna dict diagnostica dell'ultima chiamata find(). + + Halcon-equivalent: oggi inspect_shape_model espone parziali contatori. + Util per debug 'perche' 0 match', tuning interattivo, validation. + Vedi diag keys per significato (n_variants_top_evaluated, drop_*, ...). + """ + return getattr(self, "_last_diag", None)