Compare commits

...

15 Commits

Author SHA1 Message Date
Adriano 88f80a2cad fix: refine angolo piu' veloce + edge overlay ciano (no clash con asse Y)
Bug visibili dallo screenshot:
1. Rallentamento sostanziale: il fix precedente aggiungeva 16 iter golden
   (era 8) + 3 chiamate parabolic fit = ~19 _score_at_angle vs 11 prima.
2. Asse Y dell'UCS invisibile sul match: edge overlay era verde brillante
   (0,220,0) e si sovrapponeva esattamente al verde dell'asse Y dell'UCS.
3. Angolo non corretto: il parabolic fit finale era instabile su template
   simmetrici (multiple local max ravvicinati lo facevano divergere fuori
   dal vero picco trovato dal golden).

Fix:
- _refine_angle: 10 iter golden con tol 0.05 (compromesso tra precisione
  e velocita'). Rimosso parabolic fit finale instabile. search_radius
  resta a step pieno (utile per recuperare estremi del bin).
- Edge overlay color: ciano (BGR 255,200,0) invece di verde brillante.
  L'asse Y verde dell'UCS ora ben visibile sopra l'overlay.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:38:47 +02:00
Adriano d52d0d0489 merge: precisione rotazione + default Nessuna 2026-05-05 12:32:17 +02:00
Adriano 9451a418a6 fix: precisione rotazione +UI simmetria default Nessuna
Precisione rotazione:
- _refine_angle: tol 0.1 -> 0.02 deg, 8 -> 16 iter golden-section
- search_radius default = step pieno (era step/2): copre il caso peggiore
  in cui il picco vero e' all'estremo del bin angolare grezzo
- Aggiunto parabolic fit finale sui 3 punti vicini al best (precisione
  <0.01 deg quando lo score map e' smooth attorno al picco)

Default UI:
- Simmetria "Nessuna" come default (era "Invariante" che limitava
  matching a una singola pose - confondente per l'operatore tipico).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:32:17 +02:00
Adriano 2c9160e4be merge: perf profile/bench/prune 2026-05-05 12:25:15 +02:00
Adriano 6d6dcc3b7a feat: profile mode + bench suite + skip-bin-vuoti + variant pruning histogram
4 ottimizzazioni performance + visibilita':

GGG. find(profile=True) → timing per fase
- _checkpoint() registra ms tra: to_gray, spread_top, top_pruning,
  full_kernel, refine_verify_nms
- get_last_profile() ritorna dict ms per identificare bottleneck
- Costo runtime trascurabile (~5 us per call)

HHH. pm2d.bench - benchmark suite eseguibile
- 3 scenarios (rect/L/circle x scene clean/cluttered)
- 5 configs (baseline, polarity, propagate, greedy, stride)
- Auto-aggiunge gpu_umat se opencl_available()
- Tabella ms/find + profile per ogni combo
- Entry-point pm2d-bench (--quick per smoke test 2 iter)

XX. Skip dilate per bin vuoti in _spread_bitmap
- Pre-calcolo bin presenti via np.unique sui pixel valid
- Su scene a bassa varianza orientation skip 50-70% delle dilate
- Misurato benchmark: spread_top da ~0.3ms a ~0.1ms in molti casi

VV. Variant pruning preliminare via histogramma orientation
- Per ogni variante calcolo overlap (feature bins ∩ scene bins) /
  total feature bins
- Se overlap < 0.5 * min_score → skip variante (no kernel call)
- Counter n_variants_pruned_histogram nel diag
- Vantaggio: scene focalizzate (poche direzioni dominanti) skippano
  varianti template con bin assenti dalla scena

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:25:15 +02:00
Adriano ee1c4a8f92 merge: fix edge bordi spuri overlay match 2026-05-05 12:13:07 +02:00
Adriano 5002515b41 fix: rimuove edge spuri sui bordi template warpato (apparivano come ROI)
Bug: per ogni match l'overlay edge del modello includeva anche il
PERIMETRO del template warpato (transizione bordo nero borderValue=0
→ scena = forte gradient artefatto). Con N match si vedevano N
rettangoli verdi attorno ai pezzi, simili a "ROI ripetute".

Fix:
- Warpa anche _train_mask alla pose
- Erode di (2*spread_radius+1) per scartare la fascia di transizione
  bordo che produce gradient spurio
- Maschera edge_mask con warped_mask: solo edge interni al pezzo
  vengono visualizzati

Risultato: overlay edge pulito che mostra solo i veri edge del
modello allineati al pezzo trovato, niente cornici fasulle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:13:07 +02:00
Adriano 8029a1e12b merge: UCS coerente centro pose 2026-05-05 12:04:24 +02:00
Adriano d37833076e fix: UCS coerente sul centro pose, no traslazione fissata sbagliata
L'UCS del match precedentemente proiettava il baricentro feature
template alla pose, ma:
- Il baricentro veniva calcolato da una variante a 0° (v0) i cui dx/dy
  sono offsets relativi al centro PADDED (non al centro template puro)
- _extract_features dipende dai parametri matcher che possono differire
  da quelli del preview se la ricetta e' caricata
- Risultato: UCS appariva con offset costante errato rispetto al centro
  visibile del pezzo

Fix: UCS sul centro POSE del match (m.cx, m.cy) = posizione del centro
template originale nella scena (questo e' esattamente cio' che
_subpixel_peak ritorna). Coerente, prevedibile, "fissato" sul centro
del pezzo.

Per coerenza visiva, anche preview_edges sposta UCS dal baricentro al
CENTRO ROI (rh/2, rw/2). Cosi' il modello mostra UCS nello stesso
identico punto relativo dove apparira' nel match dopo
traslazione+rotazione della pose.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:04:24 +02:00
Adriano e1ed9206a3 merge: fix UCS match + edge modello overlay 2026-05-05 11:58:21 +02:00
Adriano e84ae199ac fix: UCS match dimensione + orientamento Y + overlay edge modello
3 problemi visibili da screenshot:

1. UCS match troppo grande: usava 0.4 * lato bbox (~114 px su template
   286). Anteprima modello usa 0.15 * max(lato_template) (~42 px).
   Fix: stessa formula scalata per m.scale → coerenza dimensionale.

2. Asse Y match orientamento sbagliato: a m.angle_deg=0 puntava
   in alto invece che in basso (errore segno trigonometrico:
   sin(ax + pi/2) ≠ cos(ax) per il segno y-down).
   Fix corretto:
   - X axis = (cos(ax), -sin(ax))   # rotazione cv2 di (1, 0)
   - Y axis = (sin(ax), cos(ax))    # rotazione cv2 di (0, 1)
   Verificato: a ax=0 → X destra, Y giu' (matches modello).

3. Overlay edge modello orientato (richiesta utente): warpa template
   alla pose (cx, cy, angle, scale), applica hysteresis identica al
   matcher, disegna pixel edge come overlay verde brillante (60% alpha).
   Permette di vedere visivamente l'allineamento del modello sul pezzo
   rilevato.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 11:58:21 +02:00
Adriano 5f0c4542d3 merge: param edge in find+ricetta, match solo UCS 2026-05-05 11:37:00 +02:00
Adriano 29c034fb05 fix: param edge usati anche in find/ricetta + match overlay solo UCS
Due richieste utente:

1. Param di pulizia rumore (weak/strong/num_features/spacing dal pannello
   "Anteprima edge") devono essere usati anche in find e salvati nelle
   ricette. Prima l'utente li regolava ma erano ignorati: il match usava
   sempre i valori auto_tune.

   Fix:
   - SimpleMatchParams.edge_* (4 campi opzionali): None = usa auto_tune,
     valore = override
   - _simple_to_technical applica gli override se presenti, propagati
     a min_feature_spacing nel matcher init
   - Cache key matcher include min_feature_spacing
   - SaveRecipeParams stessi 4 campi: la ricetta salva i param di
     pulizia rumore identici a quelli del preview
   - UI readEdgeOverrides() legge sempre i valori slider ed inietta
     in body sia di /match_simple sia di POST /recipes

2. Match overlay sulla scena: solo UCS (X rosso, Y verde) ruotato
   secondo m.angle_deg, posizionato sul baricentro feature del
   modello (proiettato alla pose). Niente edge filtrati, niente
   cerchietti feature, niente bbox, niente label/score sulla scena
   reale: l'overlay deve essere pulito, gli edge si vedono solo
   nell'anteprima modello.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 11:37:00 +02:00
Adriano 6fb1efcab8 merge: fix UCS match + feature pre-computate 2026-05-05 11:02:04 +02:00
Adriano 35df4c473c fix: UCS match e numero feature ora coerenti con anteprima modello
Bug visibili da screenshot:
1. UCS match diverso da UCS anteprima modello (centro pose vs baricentro)
2. Numero feature disegnate < di quelle anteprima modello

Cause:
1. Match UCS era posto su (cx, cy) = centro template, mentre l'anteprima
   modello mostra UCS sul baricentro feature (mean fx, fy).
2. _draw_matches estraeva feature dal template warpato → re-quantizza
   gradient su immagine warp+interp, perdendo precisione vs feature
   pre-computate del matcher.

Fix:
- Match.variant_idx: nuovo field con indice variante usata dal find()
- _draw_matches usa lvl0.dx/dy/bin pre-computati invece di re-estrarre:
  * applica delta-rotation (m.angle_deg - var.angle_deg) per refine
    sub-step
  * proietta in scene coords intorno a (m.cx, m.cy)
  * stesso identico set di feature dell'anteprima modello (modulo
    rotazione+traslazione)
- UCS match calcolato sul baricentro delle feature warpate, non su
  (cx, cy) → coerente con UCS anteprima

Fallback (variant_idx == -1, es. ricetta caricata da save_model
prima di questo commit): usa estrazione warpata legacy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 11:02:04 +02:00
6 changed files with 415 additions and 99 deletions
+179
View File
@@ -0,0 +1,179 @@
"""Benchmark suite per LineShapeMatcher.
Usage:
python -m pm2d.bench [--quick]
Misura tempi find() su 3 template-tipo × 3 scene-tipo × N config:
- Template: rettangolo 80×80, L-shape 120×120, cerchio 150×150
- Scene: pulita 800×600, cluttered 1080×1920, multi-pezzo 1080×1920
- Config: baseline, polarity, gpu, pyramid_propagate, greediness=0.7
Per ogni config stampa: ms/find, ms per fase (profile), n. match.
Output tabellare per detectare regressioni in CI.
"""
from __future__ import annotations
import argparse
import time
import cv2
import numpy as np
from pm2d.line_matcher import LineShapeMatcher, opencl_available
# ---------- Sintetizzatori template/scena ----------
def _tpl_rect() -> np.ndarray:
t = np.zeros((80, 80, 3), np.uint8)
cv2.rectangle(t, (15, 15), (65, 65), (255, 255, 255), 3)
return t
def _tpl_lshape() -> np.ndarray:
t = np.zeros((120, 120, 3), np.uint8)
cv2.rectangle(t, (20, 20), (50, 100), (255, 255, 255), -1)
cv2.rectangle(t, (20, 70), (100, 100), (255, 255, 255), -1)
return t
def _tpl_circle() -> np.ndarray:
t = np.zeros((150, 150, 3), np.uint8)
cv2.circle(t, (75, 75), 60, (255, 255, 255), 4)
return t
def _scene_clean(W: int, H: int, n_pieces: int = 1) -> np.ndarray:
np.random.seed(0)
s = np.zeros((H, W, 3), np.uint8)
for _ in range(n_pieces):
cx = np.random.randint(80, W - 80)
cy = np.random.randint(80, H - 80)
cv2.rectangle(s, (cx - 25, cy - 25), (cx + 25, cy + 25), (255, 255, 255), 3)
return s
def _scene_cluttered(W: int, H: int) -> np.ndarray:
np.random.seed(0)
s = np.random.randint(50, 200, (H, W, 3), np.uint8)
cv2.rectangle(s, (300, 200), (350, 250), (255, 255, 255), 3)
cv2.rectangle(s, (1500, 800), (1550, 850), (255, 255, 255), 3)
return s
# ---------- Single benchmark ----------
def _bench_config(template, scene, config_name: str,
init_kw: dict, find_kw: dict,
n_iter: int = 5) -> dict:
m = LineShapeMatcher(**init_kw)
t0 = time.perf_counter()
n_var = m.train(template)
t_train = time.perf_counter() - t0
# Warmup (Numba JIT)
m.find(scene, **find_kw)
m.find(scene, **find_kw)
# Run
times_ms = []
for _ in range(n_iter):
t0 = time.perf_counter()
matches = m.find(scene, **find_kw)
times_ms.append((time.perf_counter() - t0) * 1000.0)
# Profile (1 iter)
m.find(scene, profile=True, **find_kw)
prof = m.get_last_profile() or {}
return {
"config": config_name,
"n_variants": n_var,
"t_train_s": round(t_train, 3),
"ms_avg": round(float(np.mean(times_ms)), 1),
"ms_min": round(float(np.min(times_ms)), 1),
"ms_max": round(float(np.max(times_ms)), 1),
"n_matches": len(matches),
"profile_ms": {k: round(v, 1) for k, v in prof.items()},
}
# ---------- Suite ----------
CONFIGS = [
("baseline",
{"angle_step_deg": 10, "pyramid_levels": 2},
{"min_score": 0.4, "verify_threshold": 0.2}),
("polarity",
{"angle_step_deg": 10, "pyramid_levels": 2, "use_polarity": True},
{"min_score": 0.4, "verify_threshold": 0.2}),
("propagate",
{"angle_step_deg": 10, "pyramid_levels": 3},
{"min_score": 0.4, "verify_threshold": 0.2,
"pyramid_propagate": True, "propagate_topk": 4}),
("greedy_07",
{"angle_step_deg": 10, "pyramid_levels": 2},
{"min_score": 0.4, "verify_threshold": 0.2, "greediness": 0.7}),
("stride2",
{"angle_step_deg": 10, "pyramid_levels": 2},
{"min_score": 0.4, "verify_threshold": 0.2, "coarse_stride": 2}),
]
if opencl_available():
CONFIGS.append(
("gpu_umat",
{"angle_step_deg": 10, "pyramid_levels": 2, "use_gpu": True},
{"min_score": 0.4, "verify_threshold": 0.2})
)
SCENARIOS = [
("rect_80 vs scene_800x600", _tpl_rect, lambda: _scene_clean(800, 600, 1)),
("lshape_120 vs scene_1080x1920_clutter",
_tpl_lshape, lambda: _scene_cluttered(1920, 1080)),
("circle_150 vs scene_clean_3pieces",
_tpl_circle, lambda: _scene_clean(1920, 1080, 3)),
]
def run(quick: bool = False) -> int:
n_iter = 2 if quick else 5
print(f"=== PM2D Benchmark Suite ({len(SCENARIOS)} scenarios x "
f"{len(CONFIGS)} configs, n_iter={n_iter}) ===\n")
rows = []
for sc_name, tpl_fn, scn_fn in SCENARIOS:
template = tpl_fn()
scene = scn_fn()
print(f"--- Scenario: {sc_name} (tpl={template.shape}, "
f"scn={scene.shape}) ---")
for cfg_name, init_kw, find_kw in CONFIGS:
r = _bench_config(template, scene, cfg_name, init_kw, find_kw,
n_iter=n_iter)
r["scenario"] = sc_name
rows.append(r)
prof_str = " ".join(
f"{k}={v:.1f}" for k, v in r["profile_ms"].items()
)
print(f" {cfg_name:14s} {r['ms_avg']:6.1f}ms "
f"(min {r['ms_min']:.1f} max {r['ms_max']:.1f}) "
f"vars={r['n_variants']:3d} "
f"matches={r['n_matches']:2d}")
if prof_str:
print(f" profile: {prof_str}")
print()
print("=== Done ===")
return 0
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description="PM2D benchmark suite")
p.add_argument("--quick", action="store_true",
help="2 iterazioni per config invece di 5 (smoke test)")
args = p.parse_args(argv)
return run(quick=args.quick)
if __name__ == "__main__":
import sys
sys.exit(main())
+93 -4
View File
@@ -127,6 +127,7 @@ class Match:
scale: float
score: float
bbox_poly: np.ndarray # (4, 2) float32 - 4 vertici ordinati (ruotato)
variant_idx: int = -1 # indice variante usata (per overlay coerente)
@dataclass
@@ -735,7 +736,24 @@ class LineShapeMatcher:
nb = self._n_bins
dtype = np.uint16 if nb > 8 else np.uint8
spread = np.zeros((H, W), dtype=dtype)
# XX optimization: skip dilate per bin senza pixel attivi.
# Su scene a bassa varianza orientation (es. pezzi industriali con
# poche direzioni dominanti) tipicamente 50-70% dei bin sono vuoti.
# Pre-calcolo bin presenti via mask globale; per bin assenti niente
# dilate (resta zero nel bitmap).
if isinstance(bins, np.ndarray):
valid_bins = bins[valid] if isinstance(valid, np.ndarray) else None
if valid_bins is not None and valid_bins.size > 0:
bin_present = np.zeros(nb, dtype=bool)
unique_bins = np.unique(valid_bins)
bin_present[unique_bins[unique_bins < nb]] = True
else:
bin_present = np.zeros(nb, dtype=bool)
else:
bin_present = np.ones(nb, dtype=bool)
for b in range(nb):
if not bin_present[b]:
continue # XX: nessun pixel di questo bin sopra weak_grad
mask_b = ((bins == b) & valid).astype(np.uint8)
if self.use_gpu:
d = cv2.dilate(cv2.UMat(mask_b), kernel)
@@ -936,7 +954,7 @@ class LineShapeMatcher:
# variante e' quantizzato a multipli di angle_step (5 deg default).
# Refine angolare e' essenziale per orientamento sub-step.
if search_radius is None:
search_radius = self._effective_angle_step() / 2.0
search_radius = self._effective_angle_step()
h, w = template_gray.shape
sw = max(16, int(round(w * scale)))
@@ -1024,8 +1042,12 @@ class LineShapeMatcher:
# Score all'origine come riferimento (ang offset 0)
s0, cx0_s, cy0_s = _score_at_angle(0.0)
best = (angle_deg, s0, cx0_s, cy0_s)
tol = 0.1 # gradi
for _ in range(8):
# Precisione angolare: 10 iter golden con tol 0.05 deg.
# Compromesso speed/accuracy: il parabolic fit aggiuntivo era
# instabile su score map non-smooth (template simmetrici producono
# multipli local max ravvicinati che lo facevano divergere).
tol = 0.05
for _ in range(10):
if s1 > best[1]:
best = (angle_deg + x1, s1, cx1, cy1)
if s2 > best[1]:
@@ -1357,6 +1379,7 @@ class LineShapeMatcher:
use_soft_score: bool = False,
subpixel_lm: bool = False,
debug: bool = False,
profile: bool = False,
) -> list[Match]:
"""
scale_penalty: se > 0, riduce lo score per match a scala diversa da 1.0:
@@ -1389,6 +1412,7 @@ class LineShapeMatcher:
"drop_recall_low": 0,
"drop_bbox_out_of_scene": 0,
"drop_nms_iou": 0,
"n_variants_pruned_histogram": 0,
"n_final": 0,
"top_thresh_used": 0.0,
"verify_threshold_used": float(verify_threshold),
@@ -1400,7 +1424,21 @@ class LineShapeMatcher:
}
self._last_diag = diag
# GGG: profile mode → timing per fase, esposto via get_last_profile()
import time as _time
prof = {} if profile else None
_t_prev = _time.perf_counter() if profile else 0.0
def _checkpoint(name: str):
nonlocal _t_prev
if prof is None:
return
now = _time.perf_counter()
prof[name] = (now - _t_prev) * 1000.0 # ms
_t_prev = now
self._last_profile = prof
gray_full = self._to_gray(scene_bgr)
_checkpoint("to_gray")
# Applica ROI di ricerca: restringe scena a crop, ricorda offset per
# ri-traslare le coordinate dei match a fine pipeline.
if search_roi is not None:
@@ -1439,6 +1477,7 @@ class LineShapeMatcher:
spread0 = None
bit_active_full = None
density_full = None
_checkpoint("spread_top")
if nms_radius is None:
nms_radius = max(8, min(self.template_size) // 2)
# Pruning adattivo allo step angolare: con step piccolo (<= 3 deg)
@@ -1500,6 +1539,38 @@ class LineShapeMatcher:
end = min(n, i + half + 1)
neighbor_map[vi_c] = vi_sorted[start:end]
# VV: pruning preliminare via overlap istogramma orientation.
# Scene-bins-attivi vs variant-feature-bins. Se la variante ha bin
# dominanti che la scena non possiede → score impossibile, skip
# senza chiamare il kernel. Costo: O(n_variants * 8 ops).
scene_bins = np.array(
[bool((bit_active_top >> b) & 1) for b in range(self._n_bins)],
dtype=bool,
)
if scene_bins.any():
n_scene_active = int(scene_bins.sum())
# Soglia: variante deve avere >= 50% delle sue feature in bin
# presenti nella scena. Sotto = score certamente < 0.5.
pruned_idx_list = []
n_pruned = 0
for vi in coarse_idx_list:
lvl = self.variants[vi].levels[
min(top, len(self.variants[vi].levels) - 1)
]
if len(lvl.bin) == 0:
continue
feat_in_scene = int(np.isin(lvl.bin, np.where(scene_bins)[0]).sum())
ratio = feat_in_scene / len(lvl.bin)
if ratio < 0.5 * min_score:
n_pruned += 1
continue
pruned_idx_list.append(vi)
if n_pruned > 0 and pruned_idx_list:
coarse_idx_list = pruned_idx_list
diag["n_variants_pruned_histogram"] = n_pruned
else:
diag["n_variants_pruned_histogram"] = 0
# Pruning varianti via top-level (parallelizzato).
# coarse_stride > 1: 1 pixel ogni stride (~stride^2 speed-up).
# pyramid_propagate=True: top-K picchi per restringere full-res.
@@ -1595,6 +1666,7 @@ class LineShapeMatcher:
kept_variants: list[tuple[int, float]] = [
(vi, score_by_vi[vi]) for vi in expanded
]
_checkpoint("top_pruning")
if not kept_variants:
return []
@@ -1701,6 +1773,7 @@ class LineShapeMatcher:
raw.sort(key=lambda c: -c[0])
diag["n_raw_candidates"] = len(raw)
_checkpoint("full_kernel")
# Mappa vi → score_map per subpixel/refinement
score_maps = dict(candidates_per_var)
@@ -1756,7 +1829,10 @@ class LineShapeMatcher:
ang_f, score_f, cx_f, cy_f = self._refine_angle(
spread0, bit_active_full, self.template_gray, cx_f, cy_f,
var.angle_deg, var.scale, mask_full,
search_radius=self._effective_angle_step() / 2.0,
# Search radius esteso allo step pieno (era step/2):
# copre il caso peggiore in cui il picco vero e' all'estremo
# del bin angolare della variante grezza.
search_radius=self._effective_angle_step(),
original_score=score,
)
# Halcon SubPixel='least_squares_high': refinement iterativo
@@ -1863,10 +1939,14 @@ class LineShapeMatcher:
scale=var.scale,
score=score_f,
bbox_poly=poly,
variant_idx=int(vi),
))
if len(kept) >= max_matches:
break
diag["n_final"] = len(kept)
_checkpoint("refine_verify_nms")
if profile:
self._last_profile = prof
if debug:
# Debug mode: stampa diagnostica su stderr per visibilita' immediata.
import sys as _sys
@@ -1890,6 +1970,15 @@ class LineShapeMatcher:
f"final={diag['n_final']} (top_thresh={diag['top_thresh_used']:.2f})"
)
def get_last_profile(self) -> dict | None:
"""Ritorna timing per fase dell'ultimo find(profile=True).
Chiavi: to_gray, spread_top, top_pruning, full_kernel,
refine_verify_nms (millisecondi). Util per identificare bottleneck
dove ottimizzare.
"""
return getattr(self, "_last_profile", None)
def get_last_diag(self) -> dict | None:
"""Ritorna dict diagnostica dell'ultima chiamata find().
+113 -94
View File
@@ -78,6 +78,7 @@ def _matcher_cache_key(roi: np.ndarray, tech: dict) -> str:
h.update(roi.tobytes())
# Solo parametri che influenzano il training
relevant = ("num_features", "weak_grad", "strong_grad",
"min_feature_spacing",
"angle_min", "angle_max", "angle_step",
"scale_min", "scale_max", "scale_step",
"spread_radius", "pyramid_levels")
@@ -133,89 +134,80 @@ def _encode_png(img: np.ndarray) -> bytes:
def _draw_matches(scene: np.ndarray, matches: list[Match],
template_gray: np.ndarray | None,
matcher: "LineShapeMatcher | None" = None) -> np.ndarray:
"""Disegna match annotati sulla scena.
"""Disegna SOLO UCS (richiesta utente) per ogni match trovato.
Se matcher e' passato, usa la stessa pipeline di edge filtering
(hysteresis weak/strong_grad) e selezione feature usata in training,
cosi' l'overlay nel match riflette ESATTAMENTE quello che l'utente
ha visto nel preview "Anteprima edge". Inoltre disegna UCS
(asse X rosso, Y verde) sul centro pose del match.
Senza matcher: fallback Canny (legacy).
UCS = sistema di coordinate (X rosso, Y verde) posizionato sul
baricentro feature del modello, ruotato secondo l'angolo del match.
Niente edge, niente cerchietti feature, niente bbox: i match sulla
scena reale devono essere puliti, gli edge filtrati si vedono solo
nell'anteprima modello.
"""
out = scene.copy()
H, W = scene.shape[:2]
palette = [
(0, 255, 0), (0, 200, 255), (255, 100, 100), (255, 200, 0),
(200, 0, 255), (100, 255, 200), (255, 0, 0), (0, 255, 255),
]
bin_colors = [
(255, 0, 0), (255, 128, 0), (255, 255, 0), (0, 255, 0),
(0, 255, 255), (0, 128, 255), (0, 0, 255), (255, 0, 255),
(255, 100, 100), (255, 180, 100), (255, 230, 100), (180, 255, 100),
(100, 255, 200), (100, 180, 255), (180, 100, 255), (255, 100, 200),
]
# Lunghezza assi UCS: stessa formula dell'anteprima modello
# (0.15 * max lato template) scalata per m.scale → coerenza dimensionale.
if matcher is not None and matcher.template_size != (0, 0):
L_base = int(0.15 * max(matcher.template_size))
else:
L_base = 30
H_scene, W_scene = scene.shape[:2]
for i, m in enumerate(matches):
color = palette[i % len(palette)]
if template_gray is not None:
# UCS posizionato esattamente sul CENTRO POSE del match (m.cx, m.cy):
# equivale al centro template traslato alla scena, ruotato con
# m.angle_deg. Coerente con UCS dell'anteprima modello che ora
# e' anche sul centro ROI (vedi preview_edges).
ax = np.deg2rad(m.angle_deg)
ca, sa = np.cos(ax), np.sin(ax)
cx, cy = int(round(m.cx)), int(round(m.cy))
# Overlay edge del modello orientato (richiesta utente):
# warpa template alla pose, applica hysteresis identica al matcher,
# disegna pixel edge come overlay verde tenue. Maschera col
# _train_mask warpato + erode per rimuovere edge sui BORDI del
# rettangolo template (transizione bordo nero → scena = falso edge
# che appariva come "ROI" attorno a ogni match).
if template_gray is not None and matcher is not None:
t = template_gray
th, tw = t.shape
cx_t = (tw - 1) / 2.0; cy_t = (th - 1) / 2.0
M = cv2.getRotationMatrix2D((cx_t, cy_t), m.angle_deg, m.scale)
M[0, 2] += m.cx - cx_t
M[1, 2] += m.cy - cy_t
if matcher is not None:
# Edge filtrati con stessi param matcher (hysteresis)
warped_gray = cv2.warpAffine(
t, M, (W, H), flags=cv2.INTER_LINEAR, borderValue=0)
mag, bins = matcher._gradient(warped_gray)
if matcher.weak_grad < matcher.strong_grad:
edge_mask = matcher._hysteresis_mask(mag)
else:
edge_mask = mag >= matcher.strong_grad
# Background edge filtrati: tinta scura colore match
if edge_mask.any():
bg_overlay = np.zeros_like(out)
dark = tuple(int(c * 0.35) for c in color)
bg_overlay[edge_mask] = dark
out = cv2.addWeighted(out, 1.0, bg_overlay, 0.7, 0)
# Feature scelte: estrazione alla pose, dot colorati per bin
fx, fy, fb = matcher._extract_features(mag, bins, None)
for k in range(len(fx)):
px, py = int(fx[k]), int(fy[k])
if 0 <= px < W and 0 <= py < H:
bcol = bin_colors[int(fb[k]) % len(bin_colors)]
cv2.circle(out, (px, py), 2, bcol, -1, cv2.LINE_AA)
warped_gray = cv2.warpAffine(
t, M, (W_scene, H_scene),
flags=cv2.INTER_LINEAR, borderValue=0)
# Maschera: train_mask se disponibile, altrimenti rettangolo pieno
mask_src = (matcher._train_mask if matcher._train_mask is not None
else np.full((th, tw), 255, dtype=np.uint8))
warped_mask = cv2.warpAffine(
mask_src, M, (W_scene, H_scene),
flags=cv2.INTER_NEAREST, borderValue=0)
# Erode di spread_radius per scartare la fascia di transizione
# bordo che produce gradient spurio
er_k = max(3, 2 * matcher.spread_radius + 1)
kernel_er = np.ones((er_k, er_k), np.uint8)
warped_mask = cv2.erode(warped_mask, kernel_er)
mag, _ = matcher._gradient(warped_gray)
if matcher.weak_grad < matcher.strong_grad:
edge_mask = matcher._hysteresis_mask(mag)
else:
# Legacy Canny
edge = cv2.Canny(t, 50, 150)
warped = cv2.warpAffine(edge, M, (W, H),
flags=cv2.INTER_NEAREST, borderValue=0)
mask = warped > 0
if mask.any():
overlay = np.zeros_like(out)
overlay[mask] = color
out[mask] = (0.3 * out[mask] + 0.7 * overlay[mask]).astype(np.uint8)
# bbox poly e linea-marker rimossi (richiesta utente "togli la ROI"):
# UCS + edge filtrati gia' identificano pose e orientamento,
# il rettangolo aggiunto era ridondante e copriva il pezzo.
cx, cy = int(round(m.cx)), int(round(m.cy))
# UCS sul centro pose match (richiesta utente: come nell'anteprima
# modello). Asse X rosso destra, Y verde basso (image y-down).
# Lunghezza derivata dalla diagonale bbox per scala-invariante.
L = int(np.linalg.norm(m.bbox_poly[1] - m.bbox_poly[0])) // 2
if L < 10:
L = 30 # fallback se bbox degenere
ax = np.deg2rad(m.angle_deg)
# X axis ruotato (rosso)
x_end = (int(cx + L * np.cos(ax)), int(cy - L * np.sin(ax)))
edge_mask = mag >= matcher.strong_grad
edge_mask = edge_mask & (warped_mask > 0)
if edge_mask.any():
edge_overlay = np.zeros_like(out)
# Ciano (cambiato da verde): non collide col verde dell'asse
# Y dell'UCS che altrimenti scompariva nell'overlay edge.
edge_overlay[edge_mask] = (255, 200, 0) # ciano (BGR)
out = cv2.addWeighted(out, 1.0, edge_overlay, 0.6, 0)
L = max(20, int(L_base * m.scale))
# X axis = rotazione di (1, 0) con cv2 matrix → (cos, -sin)
x_end = (int(cx + L * ca), int(cy - L * sa))
# Y axis = rotazione di (0, 1) con cv2 matrix → (sin, cos)
# A m.angle_deg=0 deve puntare GIU' (image y-down convenzione modello)
y_end = (int(cx + L * sa), int(cy + L * ca))
cv2.arrowedLine(out, (cx, cy), x_end,
(0, 0, 255), 2, cv2.LINE_AA, tipLength=0.2)
cv2.putText(out, "X", (x_end[0] + 4, x_end[1] + 5),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1, cv2.LINE_AA)
# Y axis perpendicolare (verde, +90° in image coords = giu' visivo)
y_end = (int(cx + L * np.cos(ax + np.pi / 2)),
int(cy - L * np.sin(ax + np.pi / 2)))
cv2.arrowedLine(out, (cx, cy), y_end,
(0, 255, 0), 2, cv2.LINE_AA, tipLength=0.2)
cv2.putText(out, "Y", (y_end[0] + 4, y_end[1] + 12),
@@ -223,9 +215,6 @@ def _draw_matches(scene: np.ndarray, matches: list[Match],
# Origine UCS: cerchio bianco con bordo nero
cv2.circle(out, (cx, cy), 4, (0, 0, 0), -1, cv2.LINE_AA)
cv2.circle(out, (cx, cy), 3, (255, 255, 255), -1, cv2.LINE_AA)
label = f"#{i+1} {m.angle_deg:.0f}d s={m.scale:.2f} {m.score:.2f}"
cv2.putText(out, label, (cx + 12, cy - 12),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 2, cv2.LINE_AA)
return out
@@ -328,6 +317,15 @@ class SimpleMatchParams(BaseModel):
penalita_scala: float = 0.0 # 0 = score shape invariante, >0 = penalizza scala != 1
min_score: float = 0.65
max_matches: int = 25
# --- Override edge da pannello "Anteprima edge" (None = auto_tune) ---
# Quando settati, sovrascrivono i valori derivati da auto_tune e
# vengono usati identici sia nel training del matcher sia nel find.
# Salvati nella ricetta cosi' la stessa pulizia rumore e' replicata
# quando la ricetta viene caricata.
edge_weak_grad: float | None = None
edge_strong_grad: float | None = None
edge_num_features: int | None = None
edge_min_feature_spacing: int | None = None
# --- Halcon-mode flags (default off = backward compat) ---
# Init-time (richiede ri-train se cambiato)
use_polarity: bool = False # F: 16 bin orientation mod 2pi
@@ -376,10 +374,24 @@ def _simple_to_technical(
smin, smax, sstep = SCALE_PRESETS.get(p.scala, (1.0, 1.0, 0.1))
ang_step = PRECISION_ANGLE_STEP.get(p.precisione, 5.0)
# Override edge dal pannello "Anteprima edge" se utente li ha settati.
# Questi sostituiscono i valori auto_tune nel training del matcher,
# garantendo che la selezione edge identica a quella del preview
# venga usata sia in training sia in find.
weak_g = (p.edge_weak_grad if p.edge_weak_grad is not None
else tune["weak_grad"])
strong_g = (p.edge_strong_grad if p.edge_strong_grad is not None
else tune["strong_grad"])
n_feat = (p.edge_num_features if p.edge_num_features is not None
else nf)
min_sp = (p.edge_min_feature_spacing if p.edge_min_feature_spacing is not None
else 3)
return {
"num_features": nf,
"weak_grad": tune["weak_grad"],
"strong_grad": tune["strong_grad"],
"num_features": n_feat,
"weak_grad": weak_g,
"strong_grad": strong_g,
"min_feature_spacing": min_sp,
"spread_radius": spread,
"pyramid_levels": pyr,
"angle_min": 0.0,
@@ -615,6 +627,7 @@ def match_simple(p: SimpleMatchParams):
scale_range=(tech["scale_min"], tech["scale_max"]),
scale_step=tech["scale_step"],
spread_radius=tech["spread_radius"],
min_feature_spacing=tech.get("min_feature_spacing", 3),
pyramid_levels=tech["pyramid_levels"],
use_polarity=p.use_polarity,
use_gpu=p.use_gpu,
@@ -684,6 +697,11 @@ class SaveRecipeParams(BaseModel):
precisione: str = "normale"
use_polarity: bool = False
use_gpu: bool = False
# Override edge dal pannello "Anteprima edge" (None = auto_tune)
edge_weak_grad: float | None = None
edge_strong_grad: float | None = None
edge_num_features: int | None = None
edge_min_feature_spacing: int | None = None
name: str # nome file ricetta (no path)
@@ -750,26 +768,23 @@ def preview_edges(p: EdgePreviewParams):
b = int(fb[i])
col = bin_colors[b % len(bin_colors)]
cv2.circle(out, (int(fx[i]), int(fy[i])), 2, col, -1, cv2.LINE_AA)
# UCS sul baricentro feature (richiesta utente): assi X rosso, Y verde
bary_cx = bary_cy = None
if len(fx) > 0:
bary_cx = float(np.mean(fx))
bary_cy = float(np.mean(fy))
bx, by = int(round(bary_cx)), int(round(bary_cy))
axis_len = max(20, int(0.15 * max(out.shape[:2])))
# X axis (rosso, verso destra)
cv2.arrowedLine(out, (bx, by), (bx + axis_len, by),
(0, 0, 255), 2, cv2.LINE_AA, tipLength=0.2)
cv2.putText(out, "X", (bx + axis_len + 4, by + 5),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1, cv2.LINE_AA)
# Y axis (verde, verso il basso = convenzione image y-down)
cv2.arrowedLine(out, (bx, by), (bx, by + axis_len),
(0, 255, 0), 2, cv2.LINE_AA, tipLength=0.2)
cv2.putText(out, "Y", (bx + 4, by + axis_len + 12),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1, cv2.LINE_AA)
# Origine: cerchio bianco con bordo nero
cv2.circle(out, (bx, by), 4, (0, 0, 0), -1, cv2.LINE_AA)
cv2.circle(out, (bx, by), 3, (255, 255, 255), -1, cv2.LINE_AA)
# UCS sul CENTRO ROI (coerente con _draw_matches che usa centro pose).
# In questo modo l'UCS visualizzato nel modello = UCS del match (modulo
# rotazione/traslazione data dalla pose del pezzo trovato).
rh, rw = roi_img.shape[:2]
bx, by = (rw - 1) // 2, (rh - 1) // 2
axis_len = max(20, int(0.15 * max(rw, rh)))
cv2.arrowedLine(out, (bx, by), (bx + axis_len, by),
(0, 0, 255), 2, cv2.LINE_AA, tipLength=0.2)
cv2.putText(out, "X", (bx + axis_len + 4, by + 5),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1, cv2.LINE_AA)
cv2.arrowedLine(out, (bx, by), (bx, by + axis_len),
(0, 255, 0), 2, cv2.LINE_AA, tipLength=0.2)
cv2.putText(out, "Y", (bx + 4, by + axis_len + 12),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1, cv2.LINE_AA)
cv2.circle(out, (bx, by), 4, (0, 0, 0), -1, cv2.LINE_AA)
cv2.circle(out, (bx, by), 3, (255, 255, 255), -1, cv2.LINE_AA)
bary_cx, bary_cy = float(bx), float(by)
img_id = _store_image(out)
n_edge_strong = int((mag >= m.strong_grad).sum())
n_edge_total = int(edge_mask.sum() / 255)
@@ -801,6 +816,10 @@ def save_recipe(p: SaveRecipeParams):
tipo=p.tipo, simmetria=p.simmetria, scala=p.scala,
precisione=p.precisione,
use_polarity=p.use_polarity, use_gpu=p.use_gpu,
edge_weak_grad=p.edge_weak_grad,
edge_strong_grad=p.edge_strong_grad,
edge_num_features=p.edge_num_features,
edge_min_feature_spacing=p.edge_min_feature_spacing,
)
tech = _simple_to_technical(sp, roi_img)
m = LineShapeMatcher(
+28
View File
@@ -53,10 +53,34 @@ function readUserParams() {
document.getElementById("p-penalita-scala").value),
min_score: parseFloat(document.getElementById("p-min-score").value),
max_matches: parseInt(document.getElementById("p-max-matches").value, 10),
...readEdgeOverrides(),
...readHalconFlags(),
};
}
function readEdgeOverrides() {
// Override edge dal pannello "Anteprima edge". Settati = utente li ha
// toccati (anche se uguali al default attuale). Vengono propagati a
// _simple_to_technical e usati identici sia in training sia in find.
// Inoltre salvati nella ricetta cosi' si replicano al load.
const _v = (id, parser) => {
const el = document.getElementById(id);
if (!el) return null;
const v = parser(el.value);
return Number.isFinite(v) ? v : null;
};
// Sempre passa i valori correnti degli slider: e' la richiesta utente
// che i param di pulizia rumore vengano usati anche nel find/ricetta.
const polCb = document.getElementById("hc-use-polarity");
return {
edge_weak_grad: _v("ep-weak", parseFloat),
edge_strong_grad: _v("ep-strong", parseFloat),
edge_num_features: _v("ep-nf", parseInt),
edge_min_feature_spacing: _v("ep-sp", parseInt),
use_polarity: polCb?.checked || document.getElementById("ep-pol")?.checked,
};
}
function readHalconFlags() {
// Halcon-mode toggle: tutti i flag default-off, esposti via "Modalità Halcon"
const $cb = (id) => document.getElementById(id)?.checked ?? false;
@@ -716,6 +740,10 @@ async function saveRecipe() {
precisione: user.precisione,
use_polarity: user.use_polarity,
use_gpu: user.use_gpu,
edge_weak_grad: user.edge_weak_grad,
edge_strong_grad: user.edge_strong_grad,
edge_num_features: user.edge_num_features,
edge_min_feature_spacing: user.edge_min_feature_spacing,
name: name,
};
try {
+1 -1
View File
@@ -102,8 +102,8 @@
<div class="field">
<label>Simmetria</label>
<select id="p-simmetria">
<option value="nessuna" selected>Nessuna (0..360°)</option>
<option value="invariante">Invariante (cerchi — no rotazione)</option>
<option value="nessuna">Nessuna (0..360°)</option>
<option value="bilaterale">Bilaterale (speculare 180°)</option>
<option value="rot_3">Rotazionale 3× (120°)</option>
<option value="rot_4">Rotazionale 4× (90°)</option>
+1
View File
@@ -14,6 +14,7 @@ dependencies = [
[project.scripts]
pm2d-eval = "pm2d.eval:main"
pm2d-bench = "pm2d.bench:main"
[dependency-groups]
dev = [