Compare commits

..

84 Commits

Author SHA1 Message Date
Adriano 8c46a6ca9b fix: rimossa traslazione fissa edge overlay match
Causa principale: erode di (2*spread_radius+1) sulla maschera warpata
toglieva troppo bordo. Per spread_radius=8 → kernel 17x17 = -8px da
ogni lato. L'edge map applicata sopra mostrava i bordi spostati di ~8px
verso l'interno del pezzo, creando apparente "traslazione fissa".

Soluzione: erode 3x3 solo per rimuovere ~1px di bordo nero residuo
da warpAffine borderValue=0 (artefatto di padding). Bordi del pezzo
ora visualizzati nelle posizioni corrette.

Bonus fix: cx_t calcolato come w/2 invece di (w-1)/2, coerente con
center=diag/2.0 usato in training (era 0.5px di shift residuo per
template di lato pari).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:45:11 +02:00
Adriano d335f866a3 merge: refine veloce + UCS Y visibile 2026-05-05 12:38:47 +02:00
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
Adriano 64f2c8b5dc merge: match overlay edges+UCS, no ROI 2026-05-05 10:55:54 +02:00
Adriano 7e076deb80 feat(web): match overlay con edge filtrati + UCS + rimozione bbox ROI
_draw_matches ora coerente con anteprima modello:

- Edge filtrati con stessa pipeline matcher (hysteresis weak/strong_grad)
  e selezione feature: l'overlay del match riflette esattamente quello
  che l'utente ha visto nel preview "Anteprima edge"
- Background tinta scura su pixel hysteresis (40% colore match)
- Feature scelte come dot colorati per bin (palette 16 bin)
- UCS rosso/verde sul centro pose: asse X destra, Y giu' (image y-down),
  ruotato secondo angle del match
- Origine UCS: cerchio bianco con bordo nero per visibilita'

Rimossi (richiesta utente "togli la ROI"):
- bbox poly perimetrale: ridondante, copriva il pezzo
- linea marker primo lato: sostituita da UCS rosso

Compatibilita': se matcher non passato (es. uso esterno), fallback
Canny legacy. Tutti e 3 endpoint match (/match, /match_simple,
/match_recipe) ora propagano il matcher a _draw_matches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 10:55:54 +02:00
Adriano 852597ed51 merge: UI edge preview + UCS 2026-05-05 10:48:58 +02:00
Adriano a78884f950 feat(web): anteprima edge sul modello + tracker pulizia rumore + UCS baricentro
Pannello "🔬 Anteprima edge / pulizia rumore" sotto il canvas modello.
Permette tuning interattivo dei parametri di selezione edge per
togliere "sporcizie" (rumore di sfondo, edge spuri) prima di
trainare il matcher.

Server:
- POST /preview_edges: dato modello+ROI+param edge, ritorna immagine
  ROI con overlay:
  * heatmap magnitude gradient (sfondo)
  * verde scuro: pixel sopra hysteresis edge
  * cerchietti colorati per bin: feature scelte (palette 16 bin)
  * UCS rosso/verde sul baricentro feature (richiesta utente):
    asse X destra, Y giu' (image y-down)
  Ritorna anche stats: n_features, n_edge_strong, percentili magnitude,
  ucs_baricentro {cx, cy}

UI:
- Slider weak_grad/strong_grad/num_features/spacing + checkbox polarity
- Re-fetch debounced (200ms) ad ogni input → preview live
- Bottone "Applica ai parametri Avanzate": copia i valori scelti
  nei campi Avanzate del matcher principale
- Auto-fetch quando il pannello viene aperto

Use case: operatore vede SUBITO quali edge il matcher userebbe,
regola soglie per escludere rumore, applica e poi MATCH.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 10:48:58 +02:00
Adriano 543ae0f643 merge: UI pannello diagnostica 2026-05-05 10:41:26 +02:00
Adriano a12574f3c5 feat(web): pannello diagnostica match (CC) con hint contestuali
MatchResp ora include diag dict (CC feature). UI rendering:

- Nuovo pannello pieghevole "🔍 Diagnostica" sotto i tempi
- Per ogni match mostra:
  * pipeline pruning (vars total → top_eval → top_pass → full_eval)
  * candidati (raw → pre_nms → final)
  * drop reasons (NCC, score, recall, bbox, NMS) con counter
  * soglie effettive applicate
  * flag attivi (polarity, soft, subpix-LM)

- Quando 0 match → pannello si apre automaticamente + mostra hint
  contestuale specifico:
  * "0 candidati top" → suggerisce ↓ min_score / top_thresh
  * "tutti dropped da NCC" → ↓ verify_threshold (filtro_fp)
  * "score post-NCC sotto" → ↓ min_score
  * "recall basso" → ↓ min_recall
  * "bbox out-of-scene" → check pose / search_roi

Risolve il pattern "0 match perche'?" con guida actionable invece
del black-box. Tutti e 3 endpoint match (/match, /match_simple,
/match_recipe) propagano il diag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 10:41:26 +02:00
Adriano 110dc87b08 merge: AA eval CLI 2026-05-05 10:10:00 +02:00
Adriano 2bb2cf63cc merge: II scene cache 2026-05-05 10:09:56 +02:00
Adriano ea6a9163ad merge: CC diagnostic mode 2026-05-05 10:09:56 +02:00
Adriano 1cc7881a51 feat: pm2d.eval - validation harness CLI per LineShapeMatcher
Tool da CLI per misurare oggettivamente la qualita' del matcher
su dataset etichettato. Halcon ha questo solo nell'IDE (HDevelop),
qui esposto come modulo Python testabile in CI.

Format dataset JSON:
  - template + mask
  - params init matcher (override)
  - find_params (override per find())
  - scenes con ground_truth: lista pose attese (cx, cy, angle, scale,
    tolerance_px, tolerance_deg)

Metriche per scena: TP/FP/FN, precision, recall, IoU medio bbox,
tempo find. Aggregato: precision globale, recall, F1.

Match-to-GT criterio: distanza centro <= tolerance_px AND
|angle| <= tolerance_deg, oppure IoU bbox >= 0.3.

Use case:
- regressione: confronto config A vs B oggettivo
- tuning: trovare param ottimi via grid-search guidato da F1
- validazione pre-deploy: report TP/FP/FN su dataset prod

Esposto come entry-point pm2d-eval (pyproject.toml).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 10:09:45 +02:00
Adriano 74a332a2dd feat: scene precompute cache (II Halcon-style)
LRU cache per scena: hash su prime 64KB bytes + parametri matcher
(weak/strong_grad, spread_radius, n_bins, pyramid_levels). Quando
hit, riusa:
- piramide grays
- spread_top + bit_active_top + density_top
- spread0 + bit_active_full + density_full

Tipico use case: UI tuning con slider min_score/verify_threshold/...
produce 10+ find() consecutive su scena identica. Risparmia
Sobel+dilate+popcount duplicati (~50ms su 1080p).

Speedup misurato: ~15% find() su 1080p (54ms su 351ms). Vantaggio
maggiore su template piccoli (kernel JIT veloce → scena precompute
domina). Cache size 4, invalidata in train() (template cambiato).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 10:07:27 +02:00
Adriano dae49eb4a3 feat: diagnostic mode trasparente per find()
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) <noreply@anthropic.com>
2026-05-05 10:05:20 +02:00
Adriano 9218cb2741 chore: gitignore recipes/*.npz e rimuove Pippo.npz dal tracking
Le ricette pre-trained (binari numpy compressi) sono dati utente
specifici della macchina/ROI/template, non vanno versionati.
Rimosso Pippo.npz dal repo (mantenuto su filesystem locale).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:21:46 +02:00
Adriano 159f9089a5 merge: UI load ricetta 2026-05-04 23:20:52 +02:00
Adriano b718e81ccf feat(web): UI carica/stacca ricetta + match con ricetta caricata
Manca il path "load" della V feature: utente poteva salvare ricetta
ma non caricarla dalla UI. Aggiunto:

Server:
- POST /recipes/{name}/load: carica .npz in cache _RECIPE_MATCHERS
- POST /match_recipe: usa matcher caricato senza re-train (zero
  training time, solo find params propagati)

UI:
- Dropdown ricette disponibili (auto-refreshed da GET /recipes)
- Bottone "Carica" attiva ricetta + popola state.active_recipe
- Bottone "Stacca" torna al flow normale (training da ROI)
- Status indicator mostra ricetta attiva e dimensioni

doMatch dispatcha automaticamente:
- ricetta attiva → /match_recipe (no model/ROI necessari)
- altrimenti → /match o /match_simple come prima

Use case: ricetta tarata offline, deploy a runtime production senza
ricaricare modello+ROI ogni volta.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:20:52 +02:00
Adriano d46197a81a merge: UI bottone auto-tune 2026-05-04 23:10:07 +02:00
Adriano 37c645984f feat(web): bottone Auto-tune nella toolbar (Halcon-style)
UI esponev gia' /auto_tune endpoint ma non c'era trigger user-facing.
Aggiunto bottone toolbar accanto a MATCH:
- Calcola tutti i parametri tecnici dalla ROI selezionata (gradient,
  feature, piramide, angle_step, simmetria)
- Esegue self-validation training+find su template
- Applica i valori derivati ai campi della sezione Avanzate
- Mostra alert con riepilogo + meta diagnostica
  (simmetria detected, self-validation result, ecc.)

Endpoint /auto_tune ora ritorna anche meta (_self_score, _validation,
_symmetry_order, _orient_entropy) per feedback UI invece di filtrarli.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:10:07 +02:00
Adriano 0e148667ec merge: auto_tune self-validation 2026-05-04 23:04:10 +02:00
Adriano b5bbca0e85 merge: hysteresis edge linking 2026-05-04 23:04:10 +02:00
Adriano ca3882c59c feat: auto_tune self-validation (Halcon-style inspect_shape_model)
Nuovo helper _self_validate(): post-stima parametri, esegue dry-run
training+find sul template stesso e regola i parametri se subottimali.

Loop di auto-correzione (analogo a Halcon inspect_shape_model):
1. Se top-level piramide ha <8 feature → riduce pyramid_levels
2. Se train produce 0 varianti → dimezza weak/strong_grad
3. Se find sul template fallisce → riduce soglie + num_features
4. Se self-score < 0.7 → abbassa weak_grad

Costo: 1 train minimale (1 variante) + 1 find su canvas tpl + padding,
~50ms su template 100x100. Ne vale la pena per evitare match-time
errors su scene reali con parametri estimato male.

Esposto via auto_tune(self_validate=True) default; meta '_self_score'
e '_validation' nel dict risultato per logging UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:04:01 +02:00
Adriano 7f6571bdd1 feat: hysteresis edge linking (Halcon Contrast='auto' two-threshold)
_hysteresis_mask: edge linking via componenti connesse.
- seed = mag >= strong_grad
- weak = mag >= weak_grad
- Promuove a feature ogni componente weak che contiene almeno un
  pixel strong (connettivita' 8-vicini)

Riduce simultaneamente:
- Falsi positivi: edge debole isolato (rumore puro) escluso
- Falsi negativi: edge debole connesso a edge forte incluso
  (continuita' bordi sottili a basso contrasto)

Attivo automaticamente quando weak_grad < strong_grad. Se uguali,
fallback a sogliatura singola standard. Backward compat completo
dato che default weak=30, strong=60.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:01:54 +02:00
Adriano 7cb1ae2df7 merge: UI wiring modalita Halcon 2026-05-04 22:49:17 +02:00
Adriano 6ebb08e7a2 feat(web): wiring UI per modalita Halcon (M, Y, Z, V, X, R + altri)
UI espone tutti i nuovi flag tramite sezione pieghevole "Modalita Halcon"
nel pannello impostazioni. Default off = comportamento backward compat.

Flag esposti (checkbox + numerici):
- use_polarity (F): 16-bin orientation mod 2pi
- use_gpu (R): OpenCL UMat con silent fallback CPU
- use_soft_score (Y): score continuo cos(theta_t-theta_s)
- subpixel_lm (Z): refinement 0.05 px gradient field
- refine_pose_joint: Nelder-Mead 3D (cx,cy,theta)
- pyramid_propagate: top-K propagation a full-res
- min_recall (M): filtro feature-recall
- nms_iou_threshold (A): IoU bbox poligonale
- greediness: early-exit kernel
- coarse_stride: sub-sampling top-level
- search_roi: x,y,w,h area di ricerca

Persistenza ricette (V):
- Endpoint POST /recipes: training + save .npz in recipes/
- Endpoint GET /recipes: lista
- UI: campo nome + bottone "Salva" sotto i flag

Server SimpleMatchParams esteso con tutti i campi; pipeline match_simple
propaga init-flags al cache key (use_polarity/use_gpu = retrain) e
find-flags al m.find().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:49:11 +02:00
Adriano eba9d478a7 merge: R OpenCL UMat 2026-05-04 22:42:48 +02:00
Adriano 0df0d98aa5 merge: X ensemble multi-template (con M/Y/Z preservati) 2026-05-04 22:42:43 +02:00
Adriano b2b959e801 merge: V save/load model 2026-05-04 22:42:05 +02:00
Adriano b05246b492 merge: Z subpixel LM (M+Y preservati) 2026-05-04 22:42:00 +02:00
Adriano aeaa7fb5f7 merge: Y soft-margin gradient (con M recall preservato) 2026-05-04 22:40:26 +02:00
Adriano f347a10fad merge: M feature recall 2026-05-04 22:39:01 +02:00
Adriano 0b24be4d94 feat: use_gpu - offload Sobel/dilate via cv2.UMat (OpenCL)
Flag opzionale use_gpu=False/True su LineShapeMatcher e helper:
- opencl_available() per probe runtime
- set_gpu_enabled(bool) per attivare/disattivare globalmente

Quando attivo + cv2.ocl.haveOpenCL() True: Sobel + dilate +
warpAffine usano UMat con dispatch automatico kernel GPU
(Intel UHD, AMD, NVIDIA via OpenCL ICD). Speedup tipico 1.5-3x
sui filtri OpenCV (sec 1080p), gain finale ~10-15% sul total
find() perche' kernel JIT score-bitmap rimane CPU (Numba).

Path silently fallback CPU se OpenCL non disponibile (es. build
opencv-python senza ICD). Non rompe niente in ambienti non-GPU.

Per veri 20-50x speedup servirebbe kernel CUDA dedicato del
score-bitmap (out of scope, CPU + Numba e gia' molto buono).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:38:53 +02:00
Adriano 0296083e3c feat: add_template_view - multi-template ensemble (Halcon-style)
Aggiunge una view extra al matcher gia addestrato. Le varianti
della nuova view vengono APPENDATE a self.variants col tag view_idx
e partecipano al pruning/matching come le altre.

NCC verify usa il template della view che ha matchato (via
_get_view_template + parametro view_idx propagato a _verify_ncc).

Halcon-equivalent: create_aniso_shape_model con fusione N viste.
Use case: pezzo che cambia aspetto (chiaro/scuro, prima/dopo
trattamento, illuminazioni diverse) → un solo matcher robusto
invece di N matcher distinti.

API:
    m.train(template_chiaro)
    m.add_template_view(template_scuro)
    m.find(scene)  # match su entrambi gli aspetti

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:37:13 +02:00
Adriano 39208aadab feat: save_model / load_model - persistenza ricetta addestrata
Halcon-equivalent write_shape_model / read_shape_model. Salva su
file .npz compresso:
- Tutti i parametri matcher (incluso use_polarity)
- Template gray + maschera training
- Tutte le varianti pre-computate (con piramide flat per scrittura
  efficiente, ~12KB per template 80x80 con 28 varianti)

Caso d'uso: training offline su workstation, deploy a runtime
production senza re-train. load_model() istantaneo: skip training
(che e' il costo dominante per molte scale/angoli).

Format version 1, np.savez_compressed (zlib).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:34:54 +02:00
Adriano 2b7ee6799c feat: subpixel_lm - refinement iterativo gradient-field least-squares
_subpixel_refine_lm: per ogni feature template, calcola normale
gradient nella scena (bilineare) e stima shift (dx, dy) globale
che minimizza errore direzionale gradient field. Iterazione damped
(max 1px/iter) per stabilita.

Halcon-equivalent SubPixel='least_squares_high'. Precisione attesa
0.05 px (vs 0.5 px del fit quadratico 2D plain). Costo: ~5ms per
match aggiuntivi (negligibile vs total find).

Default off (subpixel_lm=False, backward compat). Attivare per
applicazioni di alignment/dimensional inspection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:33:55 +02:00
Adriano 5059ce1d89 feat: use_soft_score - Halcon Metric soft-margin gradient similarity
_compute_soft_score: cos(theta_template - theta_scena) continuo
(non quantizzato a bin) pesato per magnitude. Polarity-aware se
use_polarity=True (mod 2pi) else |cos| (mod pi).

Quando use_soft_score=True (default off, backward compat), lo score
finale e' fuso con quello shape: piu discriminante per match a
piccola rotazione (penalita' graduale invece di binaria on/off).

Equivalente a Halcon Metric='use_polarity' / 'ignore_global_polarity'
in find_shape_model.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:32:17 +02:00
Adriano f05dec5183 feat: min_recall - Halcon-style feature recall check post-refine
_compute_recall calcola hits/N feature template alla pose finale
(post sub-pixel refine). Equivalente Halcon MinScore originale:
quante feature shape effettivamente combaciano sul match accettato.

Param min_recall (default 0 = off, backward compat). Util quando
NCC e' alto ma poche feature reali matchano (es. match parziale
su zona di simil-tessitura). Soglia 0.7-0.9 raccomandata per
filtri stringenti.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:31:02 +02:00
Adriano f8f6a15166 fix: pruning top adattivo a angle_step (precisione preciso era peggio)
Bug osservato: con precisione "veloce" (10 deg) il matching dava
risultati migliori che con "preciso" (2 deg). Causa: con step fine
ci sono molte varianti vicine, score top-level ravvicinati e:
- top_thresh = min_score * 0.5 troppo aggressivo: scartava varianti
  valide che sarebbero state scelte al full-res
- coarse_angle_factor=2 (skip 1 ogni 2): col fine vicini sono quasi
  identici, ma il pruning skippava la migliore

Fix: quando angle_step <= 3 deg, automatic:
- top_score_factor min 0.7 (vs default 0.5)
- coarse_angle_factor = 1 (no skip varianti)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:20:35 +02:00
Adriano 5bd8fca248 fix: re-check min_score dopo NCC averaging
Bug: score finale = (shape + ncc) / 2 puo scendere sotto min_score
impostato dall'utente. La UI mostrava match con score < soglia
perche il filtro min_score era applicato solo allo shape-score
iniziale, non al risultato finale post-NCC.

Aggiunto re-check dopo averaging: scarta match con score finale
< min_score. Coerenza filtro user-facing ripristinata.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:00:32 +02:00
Adriano 796ccb8052 fix(web): simmetria invariante (0) collassava a 360 per || default
Bug JS: SYM_MAP[user.simmetria] || 360 trasforma il valore valido 0
(invariante = nessuna rotazione) in 360 = no simmetria. Risultato:
cambiare simmetria nel pannello avanzato non aveva effetto se
selezionato invariante; per le altre opzioni il valore passava
ma con potenziale altri valori 0 in futuro.

Sostituito con ?? per distinguere "chiave mancante" da "valore zero".
Stessa fix per PREC_MAP.

Inoltre allineato FP_MAP JS al server (medio 0.35 -> 0.50, ecc.)
per coerenza UI/backend.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 21:54:16 +02:00
Adriano 0a8a9365bb fix: NCC robusto + reject bbox fuori scena + threshold piu rigorosi
3 fix per match spuri ad alto score visti su scena reale:

1. NCC con guard varianza minima: se template-patch o scene-patch
   hanno std quasi-zero (zone uniformi bianche/nere) NCC e instabile
   e da false-correlation alta. Ora ritorna 0 sotto soglia varianza.

2. Reject post-bbox: se il bounding-box ruotato del match sfora
   la scena per piu del 25%, scarto (centro derivato male o scala
   incoerente). Tollera 25% out-of-bounds (bordi).

3. FILTRO_FP_MAP alzato: leggero 0.20→0.30, medio 0.35→0.50,
   forte 0.50→0.70. Default piu conservativo per evitare match
   spuri su zone con pochi edge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 21:51:43 +02:00
Adriano 9ed779637e merge: angle restrict helper 2026-05-04 17:09:09 +02:00
Adriano 077d44c3c8 merge: polarity 16-bin 2026-05-04 17:09:05 +02:00
Adriano e038ee3a1d merge: NMS poligonale IoU 2026-05-04 17:09:00 +02:00
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
Adriano 84b73dc651 feat: use_polarity 16-bin orientation (mod 2pi)
Flag opt-in use_polarity=True su LineShapeMatcher: distingue edge
chiaro->scuro da scuro->chiaro raddoppiando i bin (8 mod pi a 16
mod 2pi). Riduce match accidentali quando il template e direzionale
ma scena ha bordo opposto (es. pezzo nero su bg chiaro vs pezzo
chiaro su bg nero).

Implementazione:
- _gradient calcola atan2 mod 2pi quando use_polarity
- _spread_bitmap usa uint16 (16 bit) invece di uint8 (8 bit)
- Nuovi kernel JIT _jit_score_bitmap_rescored_u16 e
  _jit_popcount_density_u16
- Wrapper Python score_bitmap_rescored / popcount_density fanno
  dispatch su dtype dello spread

Default off (use_polarity=False) = backward compat completo, 8 bin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:07:38 +02:00
Adriano 8d8a89ac35 feat: NMS poligonale (IoU bbox ruotato) cross-variant
_poly_iou via cv2.intersectConvexConvex: IoU esatto tra bbox
orientati. Sostituisce distanza-centro nel NMS post-refine.

Vantaggio: due pezzi adiacenti con centri vicini (entro nms_radius)
ma orientamenti diversi non vengono piu fusi se overlap reale e
basso. Stesso pezzo trovato da varianti angolari diverse (centri
uguali, IoU ~1) viene correttamente droppato.

Param nms_iou_threshold default 0.3. Fallback distanza centro
(r2/4) come safety per bbox degeneri.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:04:11 +02:00
Adriano 41976f574d fix: duplicati, score saturato e angolo impreciso
3 problemi visibili da test interattivo:
1. Match duplicati: stesso oggetto trovato da varianti angolari
   diverse, NMS pre-refine non basta perche refine sposta i match.
   Aggiunto NMS post-refine cross-variant.

2. Score sempre alto/saturato a 1.0: NCC era opzionale (skip>=0.85)
   e non veniva mescolato nello score. Ora ncc_skip_above=1.01
   (NCC sempre) e score finale = (shape + NCC) / 2: piu discriminante.

3. Angolo impreciso: _refine_angle aveva early-exit per shape-score
   >= 0.99, ma quel valore satura facile (con pyramid_propagate o
   spread ampio) senza garantire angolo preciso. Rimosso early-exit:
   refine angolare e' sempre essenziale per orientamento sub-step.

Inoltre: pyramid_propagate default False (era True), riduce duplicati
da picchi propagati su angle-vicini. propagate_topk default 4 (era 8).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 16:33:58 +02:00
Adriano 4ef7a4a85f merge: dedup varianti 2026-05-04 15:46:34 +02:00
Adriano 7de7f35b7c merge: SIMD popcount fallback 2026-05-04 15:46:21 +02:00
Adriano 7b014b7f69 merge: batch_top variant-parallel kernel 2026-05-04 15:46:17 +02:00
Adriano 367ee9aaac merge: greediness (kernel greedy alternativo a rescore strided) 2026-05-04 15:45:15 +02:00
Adriano 74e5a45a39 merge: refine cache 2026-05-04 15:43:23 +02:00
Adriano 11c5160385 merge: refine_pose_joint (param list unito) 2026-05-04 15:43:19 +02:00
Adriano 07bab87cb9 merge: lazy NCC 2026-05-04 15:42:53 +02:00
Adriano a247484f36 merge: auto angle_step 2026-05-04 15:42:45 +02:00
Adriano e188df0adb merge: pyramid_propagate (con coarse_stride preservato) 2026-05-04 15:42:41 +02:00
Adriano b35d47669c merge: coarse_stride 2026-05-04 15:41:57 +02:00
Adriano fc3b0dbc3a merge: search_roi 2026-05-04 15:41:54 +02:00
Adriano 6da4dd5329 feat: dedup varianti con feature-set identico post-quantizzazione
Hash byte-exact su (dx, dy, bin) ordinati + scale. Se due varianti
post-rasterizzazione hanno lo stesso feature-set, ne tiene solo una.

Tipico caso d'uso: template con simmetrie discrete (quadrati, croci,
forme regolari) generano duplicati esatti per rotazioni multiple
del periodo. Su quadrato 80x80 con angle_step=10 deg: 36 -> 27 varianti
(~25% in meno di lavoro top-pruning).

Approccio conservativo (byte-exact): zero rischio di rimuovere varianti
distinte. Forme arrotondate (cerchi) o template asimmetrici non beneficiano
ma non vengono compromessi.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 15:37:42 +02:00
Adriano b143c6607a feat: numpy.bitwise_count come fallback SIMD per popcount
NumPy 2.0+ espone np.bitwise_count: implementato in C nativo con
intrinsics SIMD (POPCNT/AVX2 vpopcnt). Aggiunto come fallback secondo
livello quando Numba non e disponibile (es. wheel constraint, env
ristretto). Numba JIT parallel resta default: misura su 1080p 0.5ms
vs 1.6ms (bitwise_count e single-thread).

AVX2 puro su _jit_score_bitmap_rescored richiederebbe C extension
con build nativa: out-of-scope per questo branch (Numba LLVM gia
autovettorizza il loop interno).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 15:36:48 +02:00
Adriano 6704d66cd5 feat: kernel JIT batch top-max-per-variant (opt-in)
Nuovo kernel _jit_top_max_per_variant: prange esterno sulle varianti
invece di n_vars chiamate JIT separate via ThreadPoolExecutor.
Wrapper Python top_max_per_variant prepara buffer flat (offsets +
dx_flat/dy_flat/bins_flat) e bg per scala.

Default batch_top=False perche su benchmark realistici (Linux 13 core,
72-180 varianti) ThreadPoolExecutor + kernel singolo che rilascia GIL
e gia ottimale. Path batch_top=True utile come opzione per scenari
con n_vars >>> n_threads o overhead chiamate JIT dominante.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 15:35:51 +02:00
Adriano 4419c237b2 feat: greediness param con early-exit kernel JIT
Nuovo kernel _jit_score_bitmap_greedy: per ogni pixel scorre N feature
ed esce non appena hits + remaining < greediness * min_score * N.
Esposto in find() come greediness in [0..1], default 0 (backward compat).

Sostituisce il kernel rescored al top-level quando attivo: salta il
rescore background ma early-exit pixel impossibili. Util su template
con molte feature (>100) e scena con pochi pattern competitivi.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 15:33:39 +02:00
Adriano f00cf9b621 feat: cache features template per _refine_angle
Cache LRU (chiave: angolo arrotondato a 0.05deg, scale) di
(fx, fy, fb) per evitare warpAffine + gradient + extract ripetuti
durante golden-search refine. Bucket condiviso tra match della stessa
find() e tra find() consecutive sulla stessa ricetta.

Cache invalidata in train(): il template puo essere cambiato.
Limite 256 entry (sufficiente per 32 candidati x 8 valutazioni).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 15:31:37 +02:00
Adriano 4b7271094b feat: refine_pose_joint - Nelder-Mead 3D su (cx, cy, angle)
Alternativa al refine angolare 1D + subpixel quadratico: ottimizza
simultaneamente posizione e angolo con Nelder-Mead 3D inline (no
scipy). Default off (refine_pose_joint=False) per backward compat.

Vantaggio Halcon-style: un singolo iter LM/simplex stila il match a
precisione sub-pixel + sub-step in modo congiunto invece di alternare
assi. Convergenza tipica ~24 valutazioni vs ~15 (golden+quadratico)
ma piu robusto su template asimmetrici dove pose e angolo sono
fortemente correlati.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 15:30:20 +02:00
Adriano 746d1668c6 feat: NCC verify lazy con skip per shape-score alto
ncc_skip_above (default 0.85): se lo score shape e gia molto alto,
salta la verifica NCC (costosa: warp + corr per ogni match). I match
borderline 0.6-0.85 vengono comunque verificati.

Comportamento Halcon-style: NCC come tie-breaker per casi ambigui,
non come gate generalizzato. Su scene con molti match netti riduce
sensibilmente il costo della fase post-NMS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 15:28:24 +02:00
Adriano d9a40952c4 feat: angle_step auto adattivo a dimensione template
Halcon-style: angle_step_deg=0 attiva derivazione automatica
step = atan(2/max_side) deg, clampato [0.5, 10]. Template grande
ottiene step fine, piccolo step grosso. auto_tune emette il valore
calcolato direttamente.

_refine_angle ora usa _effective_angle_step() per coerenza con
training quando la modalita auto e attiva.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 15:27:35 +02:00
Adriano 27a0ef1a45 feat: coarse_stride per sub-sampling top-level
Nuovo kernel JIT _jit_score_bitmap_rescored_strided: valuta solo
pixel su griglia stride x stride al top della piramide. NMS + fase
full-res recuperano precisione. Speed-up ~stride^2 sulla fase coarse,
specie su scene grandi (1920x1080).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 15:24:44 +02:00
Adriano ba4024d252 feat: search_roi parametro find() per limitare area di ricerca
Equivalente a Halcon set_aoi: matching opera su crop locale, coord
output ri-traslate al sistema scena. Costo proporzionale a w*h del
ROI invece di W*H scena intera.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 15:22:43 +02:00
11 changed files with 3085 additions and 147 deletions
+2
View File
@@ -8,3 +8,5 @@ __pycache__/
.DS_Store
*.log
models/
# Ricette pre-trained (generate da utente, non versionare)
recipes/*.npz
+380 -12
View File
@@ -110,6 +110,118 @@ if HAS_NUMBA:
acc[y, x] *= inv
return acc
@nb.njit(cache=True, parallel=True, fastmath=True, boundscheck=False)
def _jit_score_bitmap_rescored_strided(
spread: np.ndarray,
dx: np.ndarray, dy: np.ndarray, bins: np.ndarray,
bit_active: np.uint8,
bg: np.ndarray,
stride: nb.int32,
) -> np.ndarray:
"""Variante con sub-sampling: valuta solo pixel su griglia stride×stride.
Score restituito ha stessa shape (H, W); celle non valutate = 0.
4× speed-up con stride=2 (NMS recupera precisione in full-res).
Numba prange richiede step costante: itero su indici griglia e
moltiplico per stride dentro il body.
"""
H, W = spread.shape
N = dx.shape[0]
acc = np.zeros((H, W), dtype=np.float32)
ny = (H + stride - 1) // stride
nx = (W + stride - 1) // stride
for yi in nb.prange(ny):
y = yi * stride
for i in range(N):
b = bins[i]
mask = np.uint8(1) << b
if (bit_active & mask) == 0:
continue
ddy = dy[i]
yy = y + ddy
if yy < 0 or yy >= H:
continue
ddx = dx[i]
x_lo = 0 if ddx >= 0 else -ddx
x_hi = W if ddx <= 0 else W - ddx
rem = x_lo % stride
if rem != 0:
x_lo += stride - rem
x = x_lo
while x < x_hi:
if spread[yy, x + ddx] & mask:
acc[y, x] += 1.0
x += stride
if N > 0:
inv = 1.0 / N
for yi in nb.prange(ny):
y = yi * stride
for xi in range(nx):
x = xi * stride
v = acc[y, x] * inv
bgv = bg[y, x]
if bgv < 1.0:
r = (v - bgv) / (1.0 - bgv + 1e-6)
acc[y, x] = r if r > 0.0 else 0.0
else:
acc[y, x] = 0.0
return acc
@nb.njit(cache=True, parallel=True, fastmath=True, boundscheck=False)
def _jit_score_bitmap_greedy(
spread: np.ndarray,
dx: np.ndarray, dy: np.ndarray, bins: np.ndarray,
bit_active: np.uint8,
min_score: nb.float32,
greediness: nb.float32,
) -> np.ndarray:
"""Score bitmap con early-exit greedy (no rescore background).
Per ogni pixel iteriamo le N feature; abortiamo non appena diventa
impossibile raggiungere `min_required` count anche aggiungendo
tutte le feature rimanenti. min_required = greediness * min_score * N.
greediness=0 → nessun early-exit (equivalente a kernel base).
greediness=1 → exit non appena hits + remaining < min_score * N.
Tipico: 0.7-0.9 → 2-4x speed-up senza perdere match.
"""
H, W = spread.shape
N = dx.shape[0]
acc = np.zeros((H, W), dtype=np.float32)
if N == 0:
return acc
min_req = greediness * min_score * N
inv_N = nb.float32(1.0 / N)
for y in nb.prange(H):
for x in range(W):
hits = 0
for i in range(N):
b = bins[i]
mask = np.uint8(1) << b
if (bit_active & mask) == 0:
if hits + (N - i - 1) < min_req:
break
continue
ddy = dy[i]
yy = y + ddy
if yy < 0 or yy >= H:
if hits + (N - i - 1) < min_req:
break
continue
ddx = dx[i]
xx = x + ddx
if xx < 0 or xx >= W:
if hits + (N - i - 1) < min_req:
break
continue
if spread[yy, xx] & mask:
hits += 1
else:
if hits + (N - i - 1) < min_req:
break
acc[y, x] = nb.float32(hits) * inv_N
return acc
@nb.njit(cache=True, parallel=True, fastmath=True, boundscheck=False)
def _jit_score_bitmap_rescored(
spread: np.ndarray, # uint8 (H, W)
@@ -159,6 +271,122 @@ if HAS_NUMBA:
acc[y, x] = 0.0
return acc
@nb.njit(cache=True, parallel=True, fastmath=True, boundscheck=False)
def _jit_top_max_per_variant(
spread: np.ndarray, # uint8 (H, W)
dx_flat: np.ndarray, # int32 (sum_N,)
dy_flat: np.ndarray, # int32 (sum_N,)
bins_flat: np.ndarray, # int8 (sum_N,)
offsets: np.ndarray, # int32 (n_vars+1,) prefix sum
bit_active: np.uint8,
bg_per_variant: np.ndarray, # float32 (n_vars, H, W) - 1 per scala
scale_idx: np.ndarray, # int32 (n_vars,) idx in bg_per_variant
) -> np.ndarray:
"""Batch: per ogni variante calcola max score (rescored bg), ritorna
array float32 (n_vars,). Parallelismo prange ESTERNO sulle varianti
elimina overhead di n_vars chiamate JIT separate (avg ~20us per
chiamata su template piccoli) + pool thread Python.
Pensato per fase TOP del pruning quando n_vars >> n_threads.
"""
n_vars = offsets.shape[0] - 1
H, W = spread.shape
out = np.zeros(n_vars, dtype=np.float32)
for vi in nb.prange(n_vars):
i0 = offsets[vi]; i1 = offsets[vi + 1]
N = i1 - i0
if N == 0:
out[vi] = -1.0
continue
si = scale_idx[vi]
inv = nb.float32(1.0 / N)
best = nb.float32(-1.0)
for y in range(H):
for x in range(W):
s = nb.float32(0.0)
for k in range(N):
b = bins_flat[i0 + k]
mask = np.uint8(1) << b
if (bit_active & mask) == 0:
continue
ddy = dy_flat[i0 + k]
yy = y + ddy
if yy < 0 or yy >= H:
continue
ddx = dx_flat[i0 + k]
xx = x + ddx
if xx < 0 or xx >= W:
continue
if spread[yy, xx] & mask:
s += nb.float32(1.0)
s *= inv
bgv = bg_per_variant[si, y, x]
if bgv < 1.0:
r = (s - bgv) / (1.0 - bgv + 1e-6)
if r > best:
best = r
out[vi] = best if best > 0.0 else 0.0
return out
@nb.njit(cache=True, parallel=True, fastmath=True, boundscheck=False)
def _jit_score_bitmap_rescored_u16(
spread: np.ndarray, # uint16 (H, W) - 16 bit di polarity-aware
dx: np.ndarray, dy: np.ndarray, bins: np.ndarray,
bit_active: np.uint16,
bg: np.ndarray,
) -> np.ndarray:
"""Versione uint16 di _jit_score_bitmap_rescored per polarity 16-bin.
Identica logica ma mask = uint16(1) << b dove b in [0..15]
(orientamento mod 2π invece di mod π).
"""
H, W = spread.shape
N = dx.shape[0]
acc = np.zeros((H, W), dtype=np.float32)
for y in nb.prange(H):
for i in range(N):
b = bins[i]
mask = np.uint16(1) << b
if (bit_active & mask) == 0:
continue
ddy = dy[i]
yy = y + ddy
if yy < 0 or yy >= H:
continue
ddx = dx[i]
x_lo = 0 if ddx >= 0 else -ddx
x_hi = W if ddx <= 0 else W - ddx
for x in range(x_lo, x_hi):
if spread[yy, x + ddx] & mask:
acc[y, x] += 1.0
if N > 0:
inv = 1.0 / N
for y in nb.prange(H):
for x in range(W):
v = acc[y, x] * inv
bgv = bg[y, x]
if bgv < 1.0:
r = (v - bgv) / (1.0 - bgv + 1e-6)
acc[y, x] = r if r > 0.0 else 0.0
else:
acc[y, x] = 0.0
return acc
@nb.njit(cache=True, parallel=True, fastmath=True, boundscheck=False)
def _jit_popcount_density_u16(spread: np.ndarray) -> np.ndarray:
"""Popcount per uint16 (16 bin polarity)."""
H, W = spread.shape
out = np.zeros((H, W), dtype=np.float32)
for y in nb.prange(H):
for x in range(W):
v = spread[y, x]
cnt = 0
for b in range(16):
if v & (np.uint16(1) << b):
cnt += 1
out[y, x] = float(cnt)
return out
@nb.njit(cache=True, parallel=True, fastmath=True, boundscheck=False)
def _jit_popcount_density(spread: np.ndarray) -> np.ndarray:
"""Conta bit set per pixel: ritorna (H, W) float32 in [0..8]."""
@@ -185,7 +413,25 @@ if HAS_NUMBA:
_jit_score_bitmap(spread, dx, dy, b, np.uint8(0xFF))
bg = np.zeros((32, 32), dtype=np.float32)
_jit_score_bitmap_rescored(spread, dx, dy, b, np.uint8(0xFF), bg)
_jit_score_bitmap_rescored_strided(
spread, dx, dy, b, np.uint8(0xFF), bg, np.int32(2),
)
_jit_score_bitmap_greedy(
spread, dx, dy, b, np.uint8(0xFF),
np.float32(0.5), np.float32(0.8),
)
offsets = np.array([0, 1], dtype=np.int32)
scale_idx = np.zeros(1, dtype=np.int32)
bg_pv = np.zeros((1, 32, 32), dtype=np.float32)
_jit_top_max_per_variant(
spread, dx, dy, b, offsets, np.uint8(0xFF), bg_pv, scale_idx,
)
_jit_popcount_density(spread)
spread16 = np.zeros((32, 32), dtype=np.uint16)
_jit_score_bitmap_rescored_u16(
spread16, dx, dy, b, np.uint16(0xFFFF), bg,
)
_jit_popcount_density_u16(spread16)
else: # pragma: no cover
@@ -198,6 +444,24 @@ else: # pragma: no cover
def _jit_score_bitmap_rescored(spread, dx, dy, bins, bit_active, bg):
raise RuntimeError("numba non disponibile")
def _jit_score_bitmap_rescored_strided(spread, dx, dy, bins, bit_active, bg, stride):
raise RuntimeError("numba non disponibile")
def _jit_score_bitmap_greedy(spread, dx, dy, bins, bit_active, min_score, greediness):
raise RuntimeError("numba non disponibile")
def _jit_top_max_per_variant(
spread, dx_flat, dy_flat, bins_flat, offsets, bit_active,
bg_per_variant, scale_idx,
):
raise RuntimeError("numba non disponibile")
def _jit_score_bitmap_rescored_u16(spread, dx, dy, bins, bit_active, bg):
raise RuntimeError("numba non disponibile")
def _jit_popcount_density_u16(spread):
raise RuntimeError("numba non disponibile")
def _jit_popcount_density(spread):
raise RuntimeError("numba non disponibile")
@@ -228,28 +492,132 @@ def score_bitmap(
def score_bitmap_rescored(
spread: np.ndarray, dx: np.ndarray, dy: np.ndarray, bins: np.ndarray,
bit_active: int, bg: np.ndarray,
bit_active: int, bg: np.ndarray, stride: int = 1,
) -> np.ndarray:
"""Score bitmap + rescore fusi in un solo pass (JIT)."""
"""Score bitmap + rescore fusi in un solo pass (JIT).
Dispatch per dtype: uint16 → kernel polarity 16-bin, uint8 → kernel
standard 8-bin (con eventuale stride > 1 per coarse top-level).
"""
if HAS_NUMBA and len(dx) > 0:
return _jit_score_bitmap_rescored(
np.ascontiguousarray(spread, dtype=np.uint8),
np.ascontiguousarray(dx, dtype=np.int32),
np.ascontiguousarray(dy, dtype=np.int32),
np.ascontiguousarray(bins, dtype=np.int8),
np.uint8(bit_active),
np.ascontiguousarray(bg, dtype=np.float32),
dx_c = np.ascontiguousarray(dx, dtype=np.int32)
dy_c = np.ascontiguousarray(dy, dtype=np.int32)
bins_c = np.ascontiguousarray(bins, dtype=np.int8)
bg_c = np.ascontiguousarray(bg, dtype=np.float32)
if spread.dtype == np.uint16:
spread_c = np.ascontiguousarray(spread, dtype=np.uint16)
return _jit_score_bitmap_rescored_u16(
spread_c, dx_c, dy_c, bins_c, np.uint16(bit_active), bg_c,
)
# Fallback: chiamate separate
spread_c = np.ascontiguousarray(spread, dtype=np.uint8)
if stride > 1:
return _jit_score_bitmap_rescored_strided(
spread_c, dx_c, dy_c, bins_c, np.uint8(bit_active), bg_c,
np.int32(stride),
)
return _jit_score_bitmap_rescored(
spread_c, dx_c, dy_c, bins_c, np.uint8(bit_active), bg_c,
)
# Fallback: chiamate separate (stride ignorato in fallback)
score = score_bitmap(spread, dx, dy, bins, bit_active)
out = (score - bg) / (1.0 - bg + 1e-6)
return np.maximum(0.0, out).astype(np.float32)
def score_bitmap_greedy(
spread: np.ndarray, dx: np.ndarray, dy: np.ndarray, bins: np.ndarray,
bit_active: int, min_score: float, greediness: float,
) -> np.ndarray:
"""Score bitmap con early-exit greedy. Per coarse-pass aggressivo.
Non applica rescore background: usare quando la scena ha basso clutter
o quando si vuole mass-prune varianti via top-level rapidamente.
"""
if HAS_NUMBA and len(dx) > 0:
return _jit_score_bitmap_greedy(
np.ascontiguousarray(spread, dtype=np.uint8),
np.ascontiguousarray(dx, dtype=np.int32),
np.ascontiguousarray(dy, dtype=np.int32),
np.ascontiguousarray(bins, dtype=np.int8),
np.uint8(bit_active),
np.float32(min_score), np.float32(greediness),
)
# Fallback: kernel base senza early-exit
return score_bitmap(spread, dx, dy, bins, bit_active)
def top_max_per_variant(
spread: np.ndarray,
dx_list: list, dy_list: list, bin_list: list,
bg_per_scale: dict,
variant_scales: list,
bit_active: int,
) -> np.ndarray:
"""Wrapper: prepara buffer flat e chiama kernel batch su tutte le varianti.
Parallelismo Numba prange-esterno sulle varianti (n_vars >> n_threads
tipicamente per top-pruning) → meglio del thread-pool Python che paga
overhead di n_vars chiamate JIT separate.
"""
if not HAS_NUMBA or len(dx_list) == 0:
return np.array([], dtype=np.float32)
n_vars = len(dx_list)
sizes = [len(d) for d in dx_list]
offsets = np.zeros(n_vars + 1, dtype=np.int32)
offsets[1:] = np.cumsum(sizes)
total = int(offsets[-1])
dx_flat = np.empty(total, dtype=np.int32)
dy_flat = np.empty(total, dtype=np.int32)
bins_flat = np.empty(total, dtype=np.int8)
for vi, (dx, dy, bn) in enumerate(zip(dx_list, dy_list, bin_list)):
i0 = int(offsets[vi]); i1 = int(offsets[vi + 1])
dx_flat[i0:i1] = dx
dy_flat[i0:i1] = dy
bins_flat[i0:i1] = bn
# bg per variante: indicizzato per scala
scales_unique = sorted(bg_per_scale.keys())
scale_to_idx = {s: i for i, s in enumerate(scales_unique)}
H, W = spread.shape
bg_pv = np.empty((len(scales_unique), H, W), dtype=np.float32)
for s, idx in scale_to_idx.items():
bg_pv[idx] = bg_per_scale[s]
scale_idx = np.array(
[scale_to_idx[s] for s in variant_scales], dtype=np.int32,
)
return _jit_top_max_per_variant(
np.ascontiguousarray(spread, dtype=np.uint8),
dx_flat, dy_flat, bins_flat, offsets, np.uint8(bit_active),
bg_pv, scale_idx,
)
_HAS_NP_BITCOUNT = hasattr(np, "bitwise_count")
def popcount_density(spread: np.ndarray) -> np.ndarray:
"""Conta bit set per pixel.
Order:
1) Numba JIT parallel (preferito: piu veloce su 1080p, 0.5ms vs 1.6ms)
2) numpy.bitwise_count (NumPy 2.0+, SIMD ma single-thread)
3) Fallback numpy bit-shift puro
"""
if spread.dtype == np.uint16:
spread_c = np.ascontiguousarray(spread, dtype=np.uint16)
if HAS_NUMBA:
return _jit_popcount_density(np.ascontiguousarray(spread, dtype=np.uint8))
# Fallback
return _jit_popcount_density_u16(spread_c)
if _HAS_NP_BITCOUNT:
return np.bitwise_count(spread_c).astype(np.float32, copy=False)
H, W = spread_c.shape
out = np.zeros((H, W), dtype=np.float32)
for b in range(16):
out += ((spread_c >> b) & 1).astype(np.float32)
return out
spread_c = np.ascontiguousarray(spread, dtype=np.uint8)
if HAS_NUMBA:
return _jit_popcount_density(spread_c)
if _HAS_NP_BITCOUNT:
return np.bitwise_count(spread_c).astype(np.float32, copy=False)
H, W = spread.shape
out = np.zeros((H, W), dtype=np.float32)
for b in range(8):
+131 -5
View File
@@ -152,14 +152,124 @@ def _cache_key(template_bgr: np.ndarray, mask: np.ndarray | None) -> str:
return h.hexdigest()
def auto_tune(template_bgr: np.ndarray, mask: np.ndarray | None = None) -> dict:
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.
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).
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)
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)
@@ -208,7 +318,12 @@ def auto_tune(template_bgr: np.ndarray, mask: np.ndarray | None = None) -> dict:
# 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 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
@@ -220,12 +335,15 @@ def auto_tune(template_bgr: np.ndarray, mask: np.ndarray | None = None) -> dict:
else:
min_score = 0.45
# angle step: 5° default; se simmetria, mantengo step ma range ridotto
angle_step = 5.0
# 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": 0.0,
"angle_min": angle_min,
"angle_max": angle_max,
"angle_step": angle_step,
"scale_min": 1.0,
@@ -244,7 +362,15 @@ def auto_tune(template_bgr: np.ndarray, mask: np.ndarray | None = None) -> dict:
"_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)
+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())
+217
View File
@@ -0,0 +1,217 @@
"""CLI validation harness per LineShapeMatcher.
Usage:
python -m pm2d.eval dataset.json [opzioni]
Formato dataset (JSON):
{
"template": "path/to/template.png",
"mask": "path/to/mask.png", # opzionale
"params": { # opzionali, override su matcher init
"use_polarity": true,
"angle_step_deg": 5,
...
},
"find_params": { # opzionali, passati a find()
"min_score": 0.6,
"use_soft_score": true,
...
},
"scenes": [
{
"image": "path/to/scene1.png",
"ground_truth": [
{"cx": 320.0, "cy": 240.0, "angle_deg": 12.0,
"scale": 1.0, "tolerance_px": 5.0,
"tolerance_deg": 3.0}
]
}
]
}
Output: report precision/recall/IoU/timing per ogni scena + aggregati.
"""
from __future__ import annotations
import argparse
import json
import math
import sys
import time
from pathlib import Path
import cv2
import numpy as np
from pm2d.line_matcher import LineShapeMatcher, _poly_iou, _oriented_bbox_polygon
def _load_image(path: str | Path) -> np.ndarray:
img = cv2.imread(str(path), cv2.IMREAD_UNCHANGED)
if img is None:
raise FileNotFoundError(f"Immagine non trovata: {path}")
if img.ndim == 2:
img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
return img
def _gt_to_poly(gt: dict, tw: int, th: int) -> np.ndarray:
"""Costruisce bbox poligonale per un ground truth."""
s = float(gt.get("scale", 1.0))
return _oriented_bbox_polygon(
float(gt["cx"]), float(gt["cy"]),
tw * s, th * s, float(gt["angle_deg"]),
)
def _match_to_gt(match, gt: dict, tw: int, th: int,
iou_thr: float = 0.3) -> bool:
"""True se il match corrisponde al ground truth.
Criterio: distanza centro <= tolerance_px AND |angle_deg - gt| <= tolerance_deg
OR IoU bbox >= iou_thr (fallback per pose con tolerance ampie).
"""
tol_px = float(gt.get("tolerance_px", 5.0))
tol_deg = float(gt.get("tolerance_deg", 3.0))
dx = match.cx - float(gt["cx"])
dy = match.cy - float(gt["cy"])
dist = math.hypot(dx, dy)
da = abs((match.angle_deg - float(gt["angle_deg"]) + 180) % 360 - 180)
if dist <= tol_px and da <= tol_deg:
return True
# Fallback IoU
poly_gt = _gt_to_poly(gt, tw, th)
poly_m = match.bbox_poly
if _poly_iou(poly_m, poly_gt) >= iou_thr:
return True
return False
def evaluate_scene(matcher: LineShapeMatcher, scene_bgr: np.ndarray,
gt_list: list[dict], find_params: dict,
tw: int, th: int) -> dict:
"""Esegue match e calcola TP/FP/FN per una scena."""
t0 = time.time()
matches = matcher.find(scene_bgr, **find_params)
elapsed = time.time() - t0
gt_matched = [False] * len(gt_list)
match_is_tp = [False] * len(matches)
iou_per_match = [0.0] * len(matches)
for i, m in enumerate(matches):
for j, gt in enumerate(gt_list):
if gt_matched[j]:
continue
if _match_to_gt(m, gt, tw, th):
gt_matched[j] = True
match_is_tp[i] = True
# Calcolo IoU per metrica
poly_gt = _gt_to_poly(gt, tw, th)
iou_per_match[i] = _poly_iou(m.bbox_poly, poly_gt)
break
tp = sum(match_is_tp)
fp = len(matches) - tp
fn = len(gt_list) - sum(gt_matched)
return {
"n_matches": len(matches),
"n_gt": len(gt_list),
"tp": tp, "fp": fp, "fn": fn,
"find_time_s": elapsed,
"iou_mean": float(np.mean([i for i, t in zip(iou_per_match, match_is_tp) if t])
if tp > 0 else 0.0),
"diag": (matcher.get_last_diag()
if hasattr(matcher, "get_last_diag") else None),
}
def run(dataset_path: str, scene_filter: str | None = None,
verbose: bool = False) -> dict:
"""Esegue eval su dataset, ritorna report aggregato."""
dataset_path = Path(dataset_path)
base = dataset_path.parent
with open(dataset_path) as f:
ds = json.load(f)
template = _load_image(base / ds["template"])
mask = None
if ds.get("mask"):
mask_img = cv2.imread(str(base / ds["mask"]), cv2.IMREAD_GRAYSCALE)
if mask_img is not None:
mask = (mask_img > 128).astype(np.uint8) * 255
init_params = ds.get("params", {})
find_params = ds.get("find_params", {})
matcher = LineShapeMatcher(**init_params)
n_var = matcher.train(template, mask=mask)
tw, th = matcher.template_size
print(f"Template: {ds['template']} ({tw}x{th}), {n_var} varianti")
print(f"Param matcher: {init_params}")
print(f"Param find: {find_params}")
print()
scenes = ds["scenes"]
if scene_filter:
scenes = [s for s in scenes if scene_filter in s["image"]]
rows = []
tot_tp = tot_fp = tot_fn = 0
tot_time = 0.0
for sc in scenes:
scene = _load_image(base / sc["image"])
gt = sc.get("ground_truth", [])
result = evaluate_scene(matcher, scene, gt, find_params, tw, th)
rows.append({"scene": sc["image"], **result})
tot_tp += result["tp"]; tot_fp += result["fp"]; tot_fn += result["fn"]
tot_time += result["find_time_s"]
prec = result["tp"] / max(1, result["tp"] + result["fp"])
rec = result["tp"] / max(1, result["tp"] + result["fn"])
line = (f" {sc['image']:30s} "
f"TP={result['tp']} FP={result['fp']} FN={result['fn']} "
f"P={prec:.2f} R={rec:.2f} "
f"IoU={result['iou_mean']:.2f} "
f"t={result['find_time_s']*1000:.0f}ms")
print(line)
if verbose and result["diag"] and hasattr(matcher, "_format_diag"):
print(f" diag: {matcher._format_diag(result['diag'])}")
# Aggregati
precision = tot_tp / max(1, tot_tp + tot_fp)
recall = tot_tp / max(1, tot_tp + tot_fn)
f1 = 2 * precision * recall / max(1e-9, precision + recall)
print()
print(f"AGGREGATO: precision={precision:.3f} recall={recall:.3f} "
f"F1={f1:.3f} TP={tot_tp} FP={tot_fp} FN={tot_fn}")
print(f"TIME: total={tot_time:.2f}s avg={tot_time / max(1, len(scenes)) * 1000:.0f}ms/scene")
return {
"precision": precision, "recall": recall, "f1": f1,
"tp": tot_tp, "fp": tot_fp, "fn": tot_fn,
"total_time_s": tot_time, "n_scenes": len(scenes),
"per_scene": rows,
}
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(
description="pm2d-eval: validation harness per LineShapeMatcher"
)
p.add_argument("dataset", help="JSON dataset (template + scenes + GT)")
p.add_argument("--scene-filter", default=None,
help="Filtro substring sui nomi scena (debug)")
p.add_argument("--verbose", "-v", action="store_true",
help="Stampa diag dict per ogni scena")
p.add_argument("--out", default=None,
help="Salva report JSON su file")
args = p.parse_args(argv)
report = run(args.dataset, scene_filter=args.scene_filter,
verbose=args.verbose)
if args.out:
with open(args.out, "w") as f:
json.dump(report, f, indent=2)
print(f"Report salvato: {args.out}")
return 0 if report["f1"] > 0.5 else 1
if __name__ == "__main__":
sys.exit(main())
+1164 -61
View File
File diff suppressed because it is too large Load Diff
+420 -40
View File
@@ -48,6 +48,10 @@ IMAGES_DIR = Path(_images_dir_raw)
if not IMAGES_DIR.is_absolute():
IMAGES_DIR = PROJECT_ROOT / IMAGES_DIR
# Cartella ricette pre-trained (V feature: save/load matcher)
RECIPES_DIR = PROJECT_ROOT / "recipes"
RECIPES_DIR.mkdir(exist_ok=True)
from pm2d.line_matcher import LineShapeMatcher, Match
from pm2d.auto_tune import auto_tune
@@ -74,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")
@@ -127,45 +132,93 @@ def _encode_png(img: np.ndarray) -> bytes:
def _draw_matches(scene: np.ndarray, matches: list[Match],
template_gray: np.ndarray | None) -> np.ndarray:
template_gray: np.ndarray | None,
matcher: "LineShapeMatcher | None" = None) -> np.ndarray:
"""Disegna SOLO UCS (richiesta utente) per ogni match trovato.
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),
]
# 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
edge = cv2.Canny(t, 50, 150)
cx_t = (tw - 1) / 2.0; cy_t = (th - 1) / 2.0
# Centro template coerente col training: in train si usa
# `center = (diag / 2.0, diag / 2.0)` (no -1). Usare (tw-1)/2
# introduceva uno shift di 0.5px per template di lato pari.
cx_t = tw / 2.0; cy_t = th / 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
warped = cv2.warpAffine(edge, M, (W, H),
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)
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)
poly = m.bbox_poly.astype(np.int32).reshape(-1, 1, 2)
cv2.polylines(out, [poly], True, color, 2, cv2.LINE_AA)
p0 = tuple(m.bbox_poly[0].astype(int))
p1 = tuple(m.bbox_poly[1].astype(int))
cv2.line(out, p0, p1, color, 4, cv2.LINE_AA)
cx, cy = int(round(m.cx)), int(round(m.cy))
cv2.drawMarker(out, (cx, cy), color, cv2.MARKER_CROSS, 22, 2, cv2.LINE_AA)
L = int(np.linalg.norm(m.bbox_poly[1] - m.bbox_poly[0])) // 2
a = np.deg2rad(m.angle_deg)
cv2.arrowedLine(out, (cx, cy),
(int(cx + L * np.cos(a)), int(cy - L * np.sin(a))),
color, 2, cv2.LINE_AA, tipLength=0.2)
label = f"#{i+1} {m.angle_deg:.0f}d s={m.scale:.2f} {m.score:.2f}"
cv2.putText(out, label, (cx + 8, cy - 8),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 2, cv2.LINE_AA)
# Erode minimo (3x3) per togliere SOLO artefatti border-padding
# (~1px di bordo nero da warpAffine borderValue=0). Erode piu'
# grande spostava visualmente l'edge verso l'interno e creava
# apparente "traslazione fissa" rispetto al bordo del pezzo.
kernel_er = np.ones((3, 3), 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:
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)
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),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1, cv2.LINE_AA)
# 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)
return out
@@ -213,6 +266,7 @@ class MatchResp(BaseModel):
find_time: float
num_variants: int
annotated_id: str
diag: dict | None = None # CC: diagnostica pipeline (drop reasons)
class TuneParams(BaseModel):
@@ -249,9 +303,9 @@ PRECISION_ANGLE_STEP = {
# Un operatore sceglie il livello di rigore, non un numero astratto.
FILTRO_FP_MAP = {
"off": 0.0, # disabilitato: mantieni tutti i match shape-based
"leggero": 0.20, # tollera variazioni intensità/illuminazione forti
"medio": 0.35, # default bilanciato (consigliato)
"forte": 0.50, # scarta match con intensità molto diversa dal template
"leggero": 0.30, # tollera variazioni intensità/illuminazione forti
"medio": 0.50, # default bilanciato (consigliato)
"forte": 0.70, # scarta match con intensità molto diversa dal template
}
@@ -267,6 +321,29 @@ 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
use_gpu: bool = False # R: OpenCL UMat (silent fallback)
# Find-time (no retrain)
min_recall: float = 0.0 # M: filtra match con poche feature combaciate
use_soft_score: bool = False # Y: cosine sim continua dei gradients
subpixel_lm: bool = False # Z: precisione 0.05 px
nms_iou_threshold: float = 0.3 # A: IoU bbox poligonale
coarse_stride: int = 1 # sub-sampling top-level (>=1)
pyramid_propagate: bool = False # propagazione candidati top->full
greediness: float = 0.0 # early-exit kernel (0..1)
refine_pose_joint: bool = False # Nelder-Mead 3D (cx, cy, angle)
search_roi: list[int] | None = None # [x, y, w, h] limita area
def _simple_to_technical(
@@ -301,10 +378,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,
@@ -492,7 +583,7 @@ def match(p: MatchParams):
# Render annotated image
tg = cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY)
annotated = _draw_matches(scene, matches, tg)
annotated = _draw_matches(scene, matches, tg, matcher=m)
ann_id = _store_image(annotated)
return MatchResp(
@@ -503,6 +594,7 @@ def match(p: MatchParams):
) for m_ in matches],
train_time=t_train, find_time=t_find,
num_variants=n, annotated_id=ann_id,
diag=m.get_last_diag() if hasattr(m, "get_last_diag") else None,
)
@@ -526,6 +618,9 @@ def match_simple(p: SimpleMatchParams):
tech = _simple_to_technical(p, roi_img)
key = _matcher_cache_key(roi_img, tech)
# Halcon-mode init params: incidono sul training, includere in cache key
halcon_init_key = f"|pol={p.use_polarity}|gpu={p.use_gpu}"
key = key + halcon_init_key
m = _cache_get_matcher(key)
if m is None:
m = LineShapeMatcher(
@@ -536,23 +631,37 @@ 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,
)
t0 = time.time(); n = m.train(roi_img); t_train = time.time() - t0
_cache_put_matcher(key, m)
else:
n = len(m.variants); t_train = 0.0
nms = tech["nms_radius"] if tech["nms_radius"] > 0 else None
search_roi_t = tuple(p.search_roi) if p.search_roi else None
t0 = time.time()
matches = m.find(
scene, min_score=tech["min_score"], max_matches=tech["max_matches"],
nms_radius=nms, verify_threshold=tech["verify_threshold"],
scale_penalty=tech.get("scale_penalty", 0.0),
# Halcon-mode flags
min_recall=p.min_recall,
use_soft_score=p.use_soft_score,
subpixel_lm=p.subpixel_lm,
nms_iou_threshold=p.nms_iou_threshold,
coarse_stride=p.coarse_stride,
pyramid_propagate=p.pyramid_propagate,
greediness=p.greediness,
refine_pose_joint=p.refine_pose_joint,
search_roi=search_roi_t,
)
t_find = time.time() - t0
tg = cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY)
annotated = _draw_matches(scene, matches, tg)
annotated = _draw_matches(scene, matches, tg, matcher=m)
ann_id = _store_image(annotated)
return MatchResp(
@@ -562,6 +671,7 @@ def match_simple(p: SimpleMatchParams):
) for mt in matches],
train_time=t_train, find_time=t_find,
num_variants=n, annotated_id=ann_id,
diag=m.get_last_diag() if hasattr(m, "get_last_diag") else None,
)
@@ -573,7 +683,277 @@ def tune(p: TuneParams):
x, y, w, h = p.roi
roi_img = model[y:y + h, x:x + w]
t = auto_tune(roi_img)
return {k: v for k, v in t.items() if not k.startswith("_")}
# Esponi parametri tecnici + meta diagnostica (_self_score, _validation,
# _symmetry_order, _orient_entropy) per feedback UI.
return t
# --- V: Save/Load ricette pre-trained ---
class SaveRecipeParams(BaseModel):
model_id: str
scene_id: str | None = None
roi: list[int]
# Riusa stessi param simple per training equivalente
tipo: str = "intero"
simmetria: str = "nessuna"
scala: str = "fissa"
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)
class EdgePreviewParams(BaseModel):
model_id: str
roi: list[int]
weak_grad: float = 30.0
strong_grad: float = 60.0
num_features: int = 96
min_feature_spacing: int = 3
use_polarity: bool = False
@app.post("/preview_edges")
def preview_edges(p: EdgePreviewParams):
"""Estrae edge feature dalla ROI con i parametri dati e ritorna
immagine annotata con i pixel selezionati come overlay.
Permette tuning interattivo delle soglie weak/strong_grad e
num_features per "togliere le sporcizie" (rumore di sfondo,
edge spuri) prima di trainare il matcher vero.
"""
model = _load_image(p.model_id)
if model is None:
raise HTTPException(404, "Modello non trovato")
x, y, w, h = p.roi
H_m, W_m = model.shape[:2]
x = max(0, min(int(x), W_m - 1)); y = max(0, min(int(y), H_m - 1))
w = max(1, min(int(w), W_m - x)); h = max(1, min(int(h), H_m - y))
roi_img = model[y:y + h, x:x + w]
# Matcher temporaneo solo per estrazione feature (no train completo)
m = LineShapeMatcher(
weak_grad=p.weak_grad,
strong_grad=p.strong_grad,
num_features=p.num_features,
min_feature_spacing=p.min_feature_spacing,
use_polarity=p.use_polarity,
)
gray = cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY) if roi_img.ndim == 3 else roi_img
mag, bins = m._gradient(gray)
fx, fy, fb = m._extract_features(mag, bins, None)
# Mostra anche i pixel "weak/strong" come heatmap di sfondo
out = roi_img.copy() if roi_img.ndim == 3 else cv2.cvtColor(roi_img, cv2.COLOR_GRAY2BGR)
# Overlay magnitude leggera
mag_norm = np.clip(mag / max(1.0, mag.max()) * 255, 0, 255).astype(np.uint8)
mag_color = cv2.applyColorMap(mag_norm, cv2.COLORMAP_BONE)
out = cv2.addWeighted(out, 0.6, mag_color, 0.4, 0)
# Pixel "strong" con hysteresis: contorno verde scuro tenue
if m.weak_grad < m.strong_grad:
edge_mask = m._hysteresis_mask(mag).astype(np.uint8) * 255
else:
edge_mask = (mag >= m.strong_grad).astype(np.uint8) * 255
edge_overlay = np.zeros_like(out)
edge_overlay[edge_mask > 0] = (0, 80, 0) # verde scuro
out = cv2.addWeighted(out, 1.0, edge_overlay, 0.5, 0)
# Feature scelte: cerchietti colorati per bin
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),
]
for i in range(len(fx)):
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 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)
return {
"preview_id": img_id,
"n_features": len(fx),
"n_edge_strong": n_edge_strong,
"n_edge_after_hysteresis": n_edge_total,
"mag_max": float(mag.max()),
"mag_p50": float(np.percentile(mag, 50)),
"mag_p85": float(np.percentile(mag, 85)),
"ucs_baricentro": (
{"cx": round(bary_cx, 2), "cy": round(bary_cy, 2)}
if bary_cx is not None else None
),
}
@app.post("/recipes")
def save_recipe(p: SaveRecipeParams):
"""Allena matcher e salva su disco come ricetta riutilizzabile."""
model = _load_image(p.model_id)
if model is None:
raise HTTPException(404, "Modello non trovato")
x, y, w, h = p.roi
roi_img = model[y:y + h, x:x + w]
sp = SimpleMatchParams(
model_id=p.model_id, scene_id=p.scene_id or p.model_id, roi=p.roi,
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(
num_features=tech["num_features"],
weak_grad=tech["weak_grad"], strong_grad=tech["strong_grad"],
angle_range_deg=(tech["angle_min"], tech["angle_max"]),
angle_step_deg=tech["angle_step"],
scale_range=(tech["scale_min"], tech["scale_max"]),
scale_step=tech["scale_step"],
spread_radius=tech["spread_radius"],
pyramid_levels=tech["pyramid_levels"],
use_polarity=p.use_polarity,
use_gpu=p.use_gpu,
)
m.train(roi_img)
safe_name = "".join(c for c in p.name if c.isalnum() or c in "._-")
if not safe_name:
raise HTTPException(400, "Nome ricetta non valido")
if not safe_name.endswith(".npz"):
safe_name += ".npz"
target = RECIPES_DIR / safe_name
m.save_model(str(target))
return {"name": safe_name, "size": target.stat().st_size,
"n_variants": len(m.variants)}
@app.get("/recipes")
def list_recipes():
files = []
if RECIPES_DIR.is_dir():
for f in sorted(RECIPES_DIR.glob("*.npz")):
files.append({"name": f.name, "size": f.stat().st_size})
return {"files": files, "dir": str(RECIPES_DIR)}
# Cache di matcher caricati da .npz (V feature). Key: nome ricetta.
_RECIPE_MATCHERS: OrderedDict = OrderedDict()
_RECIPE_MATCHERS_SIZE = 4
@app.post("/recipes/{name}/load")
def load_recipe(name: str):
"""Carica ricetta .npz e popola cache matcher in memoria.
Una volta caricata, /match_recipe la usa direttamente senza
re-train. Halcon-equivalent read_shape_model + handle.
"""
safe_name = "".join(c for c in name if c.isalnum() or c in "._-")
if not safe_name.endswith(".npz"):
safe_name += ".npz"
path = RECIPES_DIR / safe_name
if not path.is_file():
raise HTTPException(404, f"Ricetta non trovata: {safe_name}")
m = LineShapeMatcher.load_model(str(path))
_RECIPE_MATCHERS[safe_name] = m
_RECIPE_MATCHERS.move_to_end(safe_name)
while len(_RECIPE_MATCHERS) > _RECIPE_MATCHERS_SIZE:
_RECIPE_MATCHERS.popitem(last=False)
return {
"name": safe_name,
"n_variants": len(m.variants),
"template_size": list(m.template_size),
"use_polarity": m.use_polarity,
}
class RecipeMatchParams(BaseModel):
recipe: str
scene_id: str
# Solo find-time params (training gia' fatto offline)
min_score: float = 0.65
max_matches: int = 25
min_recall: float = 0.0
use_soft_score: bool = False
subpixel_lm: bool = False
nms_iou_threshold: float = 0.3
coarse_stride: int = 1
pyramid_propagate: bool = False
greediness: float = 0.0
refine_pose_joint: bool = False
search_roi: list[int] | None = None
verify_threshold: float = 0.5
scale_penalty: float = 0.0
@app.post("/match_recipe", response_model=MatchResp)
def match_recipe(p: RecipeMatchParams):
"""Match con ricetta pre-trained: zero training, solo find."""
safe_name = p.recipe if p.recipe.endswith(".npz") else f"{p.recipe}.npz"
m = _RECIPE_MATCHERS.get(safe_name)
if m is None:
# Auto-load on demand
path = RECIPES_DIR / safe_name
if not path.is_file():
raise HTTPException(404, f"Ricetta non trovata: {safe_name}")
m = LineShapeMatcher.load_model(str(path))
_RECIPE_MATCHERS[safe_name] = m
scene = _load_image(p.scene_id)
if scene is None:
raise HTTPException(404, "Scena non trovata")
search_roi_t = tuple(p.search_roi) if p.search_roi else None
t0 = time.time()
matches = m.find(
scene,
min_score=p.min_score, max_matches=p.max_matches,
verify_threshold=p.verify_threshold,
scale_penalty=p.scale_penalty,
min_recall=p.min_recall,
use_soft_score=p.use_soft_score,
subpixel_lm=p.subpixel_lm,
nms_iou_threshold=p.nms_iou_threshold,
coarse_stride=p.coarse_stride,
pyramid_propagate=p.pyramid_propagate,
greediness=p.greediness,
refine_pose_joint=p.refine_pose_joint,
search_roi=search_roi_t,
)
t_find = time.time() - t0
tg = m.template_gray if m.template_gray is not None else np.zeros((1, 1), np.uint8)
annotated = _draw_matches(scene, matches, tg, matcher=m)
ann_id = _store_image(annotated)
return MatchResp(
matches=[MatchResult(
cx=mt.cx, cy=mt.cy, angle_deg=mt.angle_deg, scale=mt.scale,
score=mt.score, bbox_poly=mt.bbox_poly.tolist(),
) for mt in matches],
train_time=0.0, find_time=t_find,
num_variants=len(m.variants), annotated_id=ann_id,
diag=m.get_last_diag() if hasattr(m, "get_last_diag") else None,
)
# Mount static
+412 -4
View File
@@ -19,6 +19,7 @@ const PALETTE = [
const state = {
model: null, scene: null, roi: null, drag: null,
matches: [], annotatedImg: null,
active_recipe: null, // V: ricetta caricata (string nome) o null
};
// ---------- Forms ----------
@@ -52,6 +53,63 @@ 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;
const $num = (id, def) => {
const v = parseFloat(document.getElementById(id)?.value);
return Number.isFinite(v) ? v : def;
};
const $int = (id, def) => {
const v = parseInt(document.getElementById(id)?.value, 10);
return Number.isFinite(v) ? v : def;
};
const roiStr = document.getElementById("hc-search-roi")?.value.trim() ?? "";
let search_roi = null;
if (roiStr) {
const p = roiStr.split(/[ ,;]+/).map((x) => parseInt(x, 10));
if (p.length === 4 && p.every((v) => Number.isFinite(v))) search_roi = p;
}
return {
use_polarity: $cb("hc-use-polarity"),
use_gpu: $cb("hc-use-gpu"),
use_soft_score: $cb("hc-soft-score"),
subpixel_lm: $cb("hc-subpixel-lm"),
refine_pose_joint: $cb("hc-refine-joint"),
pyramid_propagate: $cb("hc-pyr-propagate"),
min_recall: $num("hc-min-recall", 0),
nms_iou_threshold: $num("hc-nms-iou", 0.3),
greediness: $num("hc-greediness", 0),
coarse_stride: $int("hc-coarse-stride", 1),
search_roi: search_roi,
};
}
@@ -274,7 +332,43 @@ function setupROI() {
}
// ---------- Match action ----------
async function doMatchRecipe() {
if (!state.scene) { setStatus("Carica scena"); return; }
setStatus(`Match ricetta ${state.active_recipe}...`);
const hc = readHalconFlags();
const body = {
recipe: state.active_recipe,
scene_id: state.scene.id,
min_score: parseFloat(document.getElementById("p-min-score").value),
max_matches: parseInt(document.getElementById("p-max-matches").value, 10),
verify_threshold: 0.50,
...hc,
};
const r = await fetch("/match_recipe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!r.ok) { setStatus(`Errore: ${await r.text()}`); return; }
const data = await r.json();
state.matches = data.matches;
state.annotatedImg = await loadImage(
`/image/${data.annotated_id}/raw?t=${Date.now()}`);
renderScene();
renderLegend();
document.getElementById("t-train").textContent = "—";
document.getElementById("t-find").textContent = `${data.find_time.toFixed(2)}s`;
document.getElementById("t-var").textContent = data.num_variants;
document.getElementById("t-match").textContent = data.matches.length;
renderDiag(data.diag, data.matches.length);
setStatus(`${data.matches.length} match trovati (ricetta ${state.active_recipe})`);
}
async function doMatch() {
// Path V: ricetta caricata → bypass training, solo find su scena
if (state.active_recipe) {
return doMatchRecipe();
}
if (!state.model) { setStatus("Carica modello"); return; }
if (!state.scene) { setStatus("Carica scena"); return; }
if (!state.roi) { setStatus("Seleziona ROI sul modello"); return; }
@@ -294,12 +388,17 @@ async function doMatch() {
const SCALE_MAP = {fissa:[1,1,0.1], mini:[0.9,1.1,0.05],
medio:[0.75,1.25,0.05], max:[0.5,1.5,0.05]};
const PREC_MAP = {veloce:10, normale:5, preciso:2};
const FP_MAP = {off:0, leggero:0.20, medio:0.35, forte:0.50};
// Allineato a FILTRO_FP_MAP server-side (server.py)
const FP_MAP = {off:0, leggero:0.30, medio:0.50, forte:0.70};
const [smin, smax, sstep] = SCALE_MAP[user.scala];
// NB: SYM_MAP[invariante]=0 e' valido (zero rotazioni). Uso ?? per
// distinguere "chiave mancante" da "valore zero": altrimenti 0 || 360
// collassa invariante a 360 = bug "simmetria non ha effetto".
const angMax = SYM_MAP[user.simmetria] ?? 360;
body = {
model_id: state.model.id, scene_id: state.scene.id, roi: state.roi,
angle_min: 0, angle_max: SYM_MAP[user.simmetria] || 360,
angle_step: PREC_MAP[user.precisione] || 5,
angle_min: 0, angle_max: angMax,
angle_step: PREC_MAP[user.precisione] ?? 5,
scale_min: smin, scale_max: smax, scale_step: sstep,
min_score: user.min_score, max_matches: user.max_matches,
num_features: adv.num_features ?? 96,
@@ -307,7 +406,7 @@ async function doMatch() {
strong_grad: adv.strong_grad ?? 60,
spread_radius: adv.spread_radius ?? 5,
pyramid_levels: adv.pyramid_levels ?? 3,
verify_threshold: adv.verify_threshold ?? (FP_MAP[user.filtro_fp] ?? 0.35),
verify_threshold: adv.verify_threshold ?? (FP_MAP[user.filtro_fp] ?? 0.50),
nms_radius: adv.nms_radius ?? 0,
};
} else {
@@ -335,6 +434,7 @@ async function doMatch() {
document.getElementById("t-find").textContent = `${data.find_time.toFixed(2)}s`;
document.getElementById("t-var").textContent = data.num_variants;
document.getElementById("t-match").textContent = data.matches.length;
renderDiag(data.diag, data.matches.length);
setStatus(`${data.matches.length} match trovati${hasAdv ? " (avanzato)" : ""}`);
}
@@ -362,6 +462,305 @@ function setStatus(s) {
}
// ---------- Init ----------
// ---------- Edge preview (clean rumore) ----------
let _epDebounce = null;
let _epLastImg = null;
async function fetchEdgePreview() {
if (!state.model || !state.roi) {
document.getElementById("edge-preview-info").textContent =
"Disegna prima la ROI sul modello";
return;
}
const body = {
model_id: state.model.id,
roi: state.roi,
weak_grad: parseFloat(document.getElementById("ep-weak").value),
strong_grad: parseFloat(document.getElementById("ep-strong").value),
num_features: parseInt(document.getElementById("ep-nf").value, 10),
min_feature_spacing: parseInt(document.getElementById("ep-sp").value, 10),
use_polarity: document.getElementById("ep-pol").checked,
};
try {
const r = await fetch("/preview_edges", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!r.ok) throw new Error(await r.text());
const j = await r.json();
_epLastImg = await loadImage(`/image/${j.preview_id}/raw?t=${Date.now()}`);
drawEdgePreview();
const ucs = j.ucs_baricentro
? ` | UCS=(${j.ucs_baricentro.cx},${j.ucs_baricentro.cy})`
: "";
document.getElementById("edge-preview-info").innerHTML =
`<b>${j.n_features}</b> feature scelte (di ${j.n_edge_after_hysteresis} edge totali)<br>` +
`mag: max=${j.mag_max.toFixed(0)} p50=${j.mag_p50.toFixed(0)} ` +
`p85=${j.mag_p85.toFixed(0)}${ucs}`;
} catch (e) {
document.getElementById("edge-preview-info").textContent =
`Errore preview: ${e.message}`;
}
}
function drawEdgePreview() {
const cnv = document.getElementById("c-edge-preview");
if (!_epLastImg) return;
const ctx = cnv.getContext("2d");
// Fit-contain
const r = Math.min(cnv.width / _epLastImg.width,
cnv.height / _epLastImg.height);
const w = _epLastImg.width * r;
const h = _epLastImg.height * r;
const ox = (cnv.width - w) / 2;
const oy = (cnv.height - h) / 2;
ctx.fillStyle = "#000"; ctx.fillRect(0, 0, cnv.width, cnv.height);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(_epLastImg, ox, oy, w, h);
}
function scheduleEdgePreview() {
if (_epDebounce) clearTimeout(_epDebounce);
_epDebounce = setTimeout(fetchEdgePreview, 200);
}
function bindEdgePreviewControls() {
const slid = (id, valEl) => {
const el = document.getElementById(id);
const v = document.getElementById(valEl);
el.addEventListener("input", () => {
v.textContent = el.value;
scheduleEdgePreview();
});
};
slid("ep-weak", "ep-weak-v");
slid("ep-strong", "ep-strong-v");
slid("ep-nf", "ep-nf-v");
slid("ep-sp", "ep-sp-v");
document.getElementById("ep-pol").addEventListener("change",
scheduleEdgePreview);
// Auto-refresh quando il pannello viene aperto
document.getElementById("edge-preview-panel").addEventListener("toggle",
(e) => { if (e.target.open) fetchEdgePreview(); });
document.getElementById("btn-edge-apply").addEventListener("click", () => {
// Copia i valori correnti nei campi avanzati
const map = {
"ep-weak": "adv-weak_grad",
"ep-strong": "adv-strong_grad",
"ep-nf": "adv-num_features",
"ep-sp": "adv-min_feature_spacing",
};
for (const [src, dst] of Object.entries(map)) {
const dstEl = document.getElementById(dst);
if (dstEl) dstEl.value = document.getElementById(src).value;
}
// use_polarity: alla checkbox della modalita Halcon
const polCb = document.getElementById("hc-use-polarity");
if (polCb) polCb.checked = document.getElementById("ep-pol").checked;
// Apri pannello Avanzate per feedback
const advDetails = document.querySelectorAll("#col-params details");
advDetails.forEach((d) => { d.open = true; });
alert("Parametri edge applicati. Esegui MATCH per usare i valori scelti.");
});
}
// ---------- CC: Diagnostica match ----------
function renderDiag(diag, n_matches) {
const el = document.getElementById("diag-content");
if (!diag) {
el.innerHTML = '<em style="color:#888">Diagnostica non disponibile</em>';
return;
}
const dropTotal = (diag.drop_ncc_low || 0) + (diag.drop_min_score_post_avg || 0)
+ (diag.drop_recall_low || 0) + (diag.drop_bbox_out_of_scene || 0)
+ (diag.drop_nms_iou || 0);
// Hint contestuali se 0 match
let hint = "";
if (n_matches === 0) {
if (diag.n_after_pre_nms === 0) {
hint = `<div style="color:#f88; margin-top:6px">⚠ Nessun candidato sopra soglia.
Prova: ↓ <b>min_score</b> o ↓ <b>top_thresh</b> (currently ${diag.top_thresh_used.toFixed(2)})</div>`;
} else if (diag.drop_ncc_low > 0 && dropTotal === diag.drop_ncc_low) {
hint = `<div style="color:#f88; margin-top:6px">⚠ ${diag.drop_ncc_low} candidati droppati da NCC.
Prova: ↓ <b>verify_threshold</b> (filtro_fp più leggero)</div>`;
} else if (diag.drop_min_score_post_avg > 0) {
hint = `<div style="color:#f88; margin-top:6px">⚠ ${diag.drop_min_score_post_avg} match sotto min_score post-NCC.
Prova: ↓ <b>min_score</b></div>`;
} else if (diag.drop_recall_low > 0) {
hint = `<div style="color:#f88; margin-top:6px">⚠ ${diag.drop_recall_low} match con recall < ${diag.min_recall_used}.
Prova: ↓ <b>min_recall</b></div>`;
} else if (diag.drop_bbox_out_of_scene > 0) {
hint = `<div style="color:#f88; margin-top:6px">⚠ ${diag.drop_bbox_out_of_scene} match con bbox fuori scena.
Centro derivato male: aumenta <b>min_score</b> o restringi <b>search_roi</b></div>`;
}
}
const flags = [];
if (diag.use_polarity) flags.push("polarity");
if (diag.use_soft_score) flags.push("soft");
if (diag.subpixel_lm) flags.push("subpix-LM");
el.innerHTML = `
<div><b>Pipeline pruning:</b></div>
<div>varianti: ${diag.n_variants_total} → top_eval=${diag.n_variants_top_evaluated}
→ top_pass=${diag.n_variants_top_passed} → full_eval=${diag.n_variants_full_evaluated}</div>
<div><b>Candidati:</b> raw=${diag.n_raw_candidates}
→ pre_nms=${diag.n_after_pre_nms} → final=${diag.n_final}</div>
<div><b>Drop reasons:</b> NCC=${diag.drop_ncc_low}, score=${diag.drop_min_score_post_avg},
recall=${diag.drop_recall_low}, bbox=${diag.drop_bbox_out_of_scene}, NMS=${diag.drop_nms_iou}</div>
<div><b>Soglie:</b> top=${diag.top_thresh_used.toFixed(2)},
min_score=${diag.min_score_used.toFixed(2)},
NCC=${diag.verify_threshold_used.toFixed(2)},
recall=${diag.min_recall_used.toFixed(2)}</div>
${flags.length ? `<div><b>Flag attivi:</b> ${flags.join(", ")}</div>` : ""}
${hint}
`;
// Auto-apri pannello se 0 match (segnala problema)
if (n_matches === 0) {
document.getElementById("diag-panel").open = true;
}
}
// ---------- Auto-tune (Halcon-style) ----------
async function doAutoTune() {
if (!state.model || !state.roi) {
alert("Seleziona modello e disegna ROI prima di Auto-tune.");
return;
}
const status = document.getElementById("status");
status.textContent = "Analisi ROI in corso...";
try {
const r = await fetch("/auto_tune", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model_id: state.model.id,
roi: state.roi,
}),
});
if (!r.ok) throw new Error(await r.text());
const t = await r.json();
// Applica ai campi avanzati (override automatico)
for (const [key] of ADV_PARAMS) {
const el = document.getElementById(`adv-${key}`);
if (el && t[key] !== undefined) el.value = String(t[key]);
}
// Espandi la sezione Avanzate per mostrare i valori applicati
const advDetails = document.querySelector("#col-params details:last-of-type");
if (advDetails) advDetails.open = true;
// Feedback diagnostico
const lines = [
`weak/strong: ${t.weak_grad} / ${t.strong_grad}`,
`feature: ${t.num_features}, piramide: ${t.pyramid_levels}`,
`angle: [${t.angle_min}..${t.angle_max}]@${t.angle_step}°`,
];
if (t._symmetry_order > 1) {
lines.push(`simmetria rotaz. ${t._symmetry_order}x (conf ${t._symmetry_conf})`);
}
if (t._self_score !== undefined) {
lines.push(`self-validation: ${t._validation}`);
}
status.textContent = `Auto-tune OK — ${lines[0]}`;
alert("Auto-tune completato:\n\n" + lines.join("\n"));
} catch (e) {
status.textContent = `Auto-tune errore: ${e.message}`;
alert(`Errore auto-tune: ${e.message}`);
}
}
// ---------- V: Recipe load/list/unload ----------
async function refreshRecipeList() {
try {
const r = await fetch("/recipes");
if (!r.ok) return;
const j = await r.json();
const sel = document.getElementById("hc-recipe-list");
const cur = sel.value;
sel.innerHTML = '<option value="">— ricette disponibili —</option>';
for (const f of j.files) {
const o = document.createElement("option");
o.value = f.name;
o.textContent = `${f.name} (${(f.size / 1024).toFixed(1)} KB)`;
sel.appendChild(o);
}
if (cur) sel.value = cur;
} catch (e) { /* silent */ }
}
async function loadRecipe() {
const sel = document.getElementById("hc-recipe-list");
const name = sel.value;
if (!name) {
alert("Seleziona una ricetta dalla lista.");
return;
}
try {
const r = await fetch(`/recipes/${encodeURIComponent(name)}/load`, {
method: "POST",
});
if (!r.ok) throw new Error(await r.text());
const j = await r.json();
state.active_recipe = j.name;
document.getElementById("recipe-status").textContent =
`Caricata: ${j.name}${j.n_variants} varianti, ` +
`${j.template_size[0]}x${j.template_size[1]} px` +
(j.use_polarity ? " (polarity)" : "");
document.getElementById("recipe-status").style.color = "#0c0";
document.getElementById("btn-unload-recipe").disabled = false;
} catch (e) {
alert(`Errore caricamento: ${e.message}`);
}
}
function unloadRecipe() {
state.active_recipe = null;
document.getElementById("recipe-status").textContent = "Nessuna ricetta caricata";
document.getElementById("recipe-status").style.color = "#888";
document.getElementById("btn-unload-recipe").disabled = true;
}
// ---------- V: Save recipe ----------
async function saveRecipe() {
if (!state.model || !state.roi) {
alert("Seleziona modello e disegna ROI prima di salvare la ricetta.");
return;
}
const name = document.getElementById("hc-recipe-name").value.trim();
if (!name) {
alert("Inserisci un nome per la ricetta.");
return;
}
const user = readUserParams();
const body = {
model_id: state.model.id,
scene_id: state.scene?.id || state.model.id,
roi: state.roi,
tipo: user.tipo,
simmetria: user.simmetria,
scala: user.scala,
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 {
const r = await fetch("/recipes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!r.ok) throw new Error(await r.text());
const j = await r.json();
alert(`Ricetta salvata: ${j.name}\n${j.n_variants} varianti, ${j.size} bytes`);
refreshRecipeList();
} catch (e) {
alert(`Errore salvataggio: ${e.message}`);
}
}
window.addEventListener("DOMContentLoaded", async () => {
buildAdvancedForm();
setupROI();
@@ -389,6 +788,15 @@ window.addEventListener("DOMContentLoaded", async () => {
e.target.value = ""; // consente re-upload stesso file
});
document.getElementById("btn-match").addEventListener("click", doMatch);
document.getElementById("btn-autotune").addEventListener("click", doAutoTune);
document.getElementById("btn-save-recipe").addEventListener("click",
saveRecipe);
document.getElementById("btn-load-recipe").addEventListener("click",
loadRecipe);
document.getElementById("btn-unload-recipe").addEventListener("click",
unloadRecipe);
refreshRecipeList();
bindEdgePreviewControls();
const slider = document.getElementById("p-min-score");
slider.addEventListener("input", (e) => {
document.getElementById("v-score").textContent =
+120 -1
View File
@@ -26,6 +26,10 @@
<div class="picker-list"></div>
</div>
<button class="btn btn-go" id="btn-match">▶ MATCH</button>
<button class="btn" id="btn-autotune"
title="Analizza ROI e derivata parametri ottimali (Halcon-style)">
⚙ Auto-tune
</button>
<label class="btn" title="Carica nuovo file nella cartella immagini">
⬆ Carica file
<input type="file" id="file-upload" accept="image/*" hidden>
@@ -41,6 +45,40 @@
<canvas id="c-model" width="380" height="420"></canvas>
</div>
<div id="roi-info">ROI: (nessuna)</div>
<details id="edge-preview-panel" style="margin-top:10px">
<summary>🔬 Anteprima edge / pulizia rumore</summary>
<div style="font-size:11px; color:#aaa; margin:4px 0">
Regola le soglie per togliere edge spuri (sporcizie). UCS rosso/verde
sul baricentro feature.
</div>
<div class="ep-grid">
<label class="ep-row">weak_grad <span id="ep-weak-v">30</span>
<input type="range" id="ep-weak" min="5" max="200" value="30" step="1">
</label>
<label class="ep-row">strong_grad <span id="ep-strong-v">60</span>
<input type="range" id="ep-strong" min="10" max="400" value="60" step="1">
</label>
<label class="ep-row">num_features <span id="ep-nf-v">96</span>
<input type="range" id="ep-nf" min="16" max="300" value="96" step="1">
</label>
<label class="ep-row">spacing <span id="ep-sp-v">3</span>
<input type="range" id="ep-sp" min="1" max="15" value="3" step="1">
</label>
<label class="ep-row" style="flex-direction:row; gap:6px">
<input type="checkbox" id="ep-pol"> polarity
</label>
<button class="btn" id="btn-edge-apply" type="button"
style="grid-column:1/-1">
✓ Applica ai parametri Avanzate
</button>
</div>
<div class="canvas-wrap" style="margin-top:6px">
<canvas id="c-edge-preview" width="380" height="380"></canvas>
</div>
<div id="edge-preview-info" style="font-size:11px; color:#888; margin-top:4px">
Disegna ROI e apri questo pannello per generare anteprima
</div>
</details>
</section>
<section class="col" id="col-scene">
@@ -64,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>
@@ -129,6 +167,77 @@
<input type="number" id="p-max-matches" value="25" min="1" max="200">
</div>
<details>
<summary>Modalità Halcon</summary>
<div class="halcon-grid">
<label class="hc-row" title="16-bin orientation polarity-aware (mod 2π)">
<input type="checkbox" id="hc-use-polarity">
<span>Polarity 16-bin (F)</span>
</label>
<label class="hc-row" title="Score continuo cos(θ_t-θ_s) invece di bin">
<input type="checkbox" id="hc-soft-score">
<span>Soft-margin score (Y)</span>
</label>
<label class="hc-row" title="Sub-pixel refinement gradient field LM">
<input type="checkbox" id="hc-subpixel-lm">
<span>Sub-pixel LM 0.05 px (Z)</span>
</label>
<label class="hc-row" title="Refine congiunto Nelder-Mead (cx,cy,θ)">
<input type="checkbox" id="hc-refine-joint">
<span>Refine pose joint</span>
</label>
<label class="hc-row" title="Pyramid candidates propagation">
<input type="checkbox" id="hc-pyr-propagate">
<span>Pyramid propagate</span>
</label>
<label class="hc-row" title="OpenCL GPU offload (silent fallback CPU)">
<input type="checkbox" id="hc-use-gpu">
<span>GPU OpenCL (R)</span>
</label>
<div class="hc-row hc-num">
<label>Min recall (M)</label>
<input type="number" id="hc-min-recall" value="0.0" min="0" max="1" step="0.05">
</div>
<div class="hc-row hc-num">
<label>NMS IoU thr (A)</label>
<input type="number" id="hc-nms-iou" value="0.3" min="0" max="1" step="0.05">
</div>
<div class="hc-row hc-num">
<label>Greediness</label>
<input type="number" id="hc-greediness" value="0.0" min="0" max="1" step="0.1">
</div>
<div class="hc-row hc-num">
<label>Coarse stride</label>
<input type="number" id="hc-coarse-stride" value="1" min="1" max="4" step="1">
</div>
<div class="hc-row hc-num" style="grid-column:1/-1">
<label title="Limita area di ricerca scena: x,y,w,h (vuoto = tutta scena)">
Search ROI (x,y,w,h)
</label>
<input type="text" id="hc-search-roi" placeholder="es. 100,50,800,400">
</div>
<div class="hc-row" style="grid-column:1/-1; border-top:1px solid #444; padding-top:8px">
<label>Ricetta pre-trained (V)</label>
<div style="display:flex; gap:6px; margin-top:4px">
<input type="text" id="hc-recipe-name" placeholder="nome_ricetta" style="flex:1">
<button class="btn" id="btn-save-recipe" type="button">💾 Salva</button>
</div>
<div style="display:flex; gap:6px; margin-top:6px; align-items:center">
<select id="hc-recipe-list" style="flex:1">
<option value="">— ricette disponibili —</option>
</select>
<button class="btn" id="btn-load-recipe" type="button">📂 Carica</button>
<button class="btn" id="btn-unload-recipe" type="button" disabled>✖ Stacca</button>
</div>
<div id="recipe-status" style="margin-top:4px; font-size:11px; color:#888">
Nessuna ricetta caricata
</div>
</div>
</div>
</details>
<details>
<summary>Avanzate</summary>
<div id="adv-form"></div>
@@ -139,6 +248,16 @@
<div class="kv"><span>find:</span><span id="t-find">-</span></div>
<div class="kv"><span>varianti:</span><span id="t-var">-</span></div>
<div class="kv"><span>match:</span><span id="t-match">-</span></div>
<details id="diag-panel" style="margin-top:10px">
<summary>🔍 Diagnostica (CC)</summary>
<div id="diag-content" style="font-family:monospace; font-size:11px;
background:#1a1a1a; padding:8px;
border-radius:3px; margin-top:6px;
line-height:1.5">
<em style="color:#888">Esegui un MATCH per vedere la diagnostica</em>
</div>
</details>
</section>
</main>
+32
View File
@@ -156,3 +156,35 @@ footer h2 {
}
#col-model, #col-scene { min-width: 0; }
/* Halcon-mode panel */
.halcon-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px 12px;
margin-top: 6px;
font-size: 12px;
}
.hc-row {
display: flex; align-items: center; gap: 6px;
}
.hc-row.hc-num {
flex-direction: column; align-items: flex-start;
}
.hc-row.hc-num label { font-size: 11px; color: #aaa; }
.hc-row.hc-num input { width: 100%; }
/* Edge preview panel */
.ep-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px 12px;
margin-top: 6px;
font-size: 12px;
}
.ep-row {
display: flex; flex-direction: column; gap: 2px;
font-size: 11px; color: #aaa;
}
.ep-row input[type="range"] { width: 100%; }
.ep-row span { color: #fff; font-weight: bold; font-family: monospace; }
+4
View File
@@ -12,6 +12,10 @@ dependencies = [
"uvicorn[standard]>=0.34",
]
[project.scripts]
pm2d-eval = "pm2d.eval:main"
pm2d-bench = "pm2d.bench:main"
[dependency-groups]
dev = [
"httpx>=0.28.1",