Compare commits

..

26 Commits

Author SHA1 Message Date
Adriano caabb05023 chore: de-versiona Test/ + ignora images/ e .omc/
Test/ (34 png, ~8MB) resta su disco ma esce dal versioning: la suite
benchmark le richiede in locale. images/ e' il volume di persistenza
upload della webapp (dati utente), .omc/ stato locale tooling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 12:08:41 +00:00
Adriano 2f15a37358 merge: precisione rotazione + perf propagate + robustezza server
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:54:48 +00:00
Adriano 4356a47d06 docs: roadmap Fase 2 (precisione misurata, valutazione C++ vs algoritmico)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:54:40 +00:00
Adriano 9458173ad0 fix: robustezza web/gui/legacy (lock matcher, LRU cache, clamp ROI, overlay)
- server: lock globale matcher (race nel threadpool FastAPI), LRU su
  _IMG_CACHE e _RECIPE_MATCHERS (leak), clamp ROI in tutti gli endpoint
  (400/422 invece di crash 500, check train senza varianti),
  filtro_fp=off disabilita davvero il verify NCC, fallback FILTRO_FP_MAP
  = medio, verify_threshold ricetta allineato a 0.4, _draw_matches su
  crop locale (era warp+Sobel full-frame per ogni match), spread_radius
  default 5->4
- gui: centro overlay edge (W-1)/2 -> W/2 (coerenza col train),
  spread_radius 5->4
- matcher legacy: _angle_list include estremo, cap candidati top-level,
  save/load persiste template_gray
- auto_tune: ref centrato fuori dal loop angoli
- test_suite: check imread con errore chiaro

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:54:40 +00:00
Adriano cc811fdc94 fix: precisione rotazione sub-0.1° + refine least-squares + propagate windowed
Root cause rotazione imprecisa: score saturo sulla spread bitmap dilatata
(raggio 4-5) -> refine senza gradiente (angolo restava quantizzato allo
step) e minMaxLoc sul plateau spostava il centro sull'angolo finestra
(errore sistematico 3*sqrt(2) px).

- _refine_angle: ottimizza su bitmap fine raggio 1 (spread_fine, in cache
  scena), picco sub-pixel con centroide plateau, score finale ricalcolato
  su spread coarse (semantica soglie invariata)
- _subpixel_refine_lm riscritto: snap edge sub-pixel lungo la normale +
  LSQ 3x3 (dx, dy, dtheta), ON di default, Sobel scena precomputato
- _prepare_padded_template: centro rotazione coerente col padding
- round invece di truncation sugli offset feature (bias 0.25px)
- _angle_list include estremo superiore del range
- _refine_pose_joint rimosso (NM su funzione a gradini, terminava subito)
- pyramid_propagate default ON con kernel windowed (le feature campionano
  l'intera scena: il crop precedente le troncava -> score 0), picchi =
  massimi locali, auto-off per template elongati >2:1
- piramide 3 livelli default con clamp su dimensione template
- cache scena: hash dell'intera immagine (64KB collidevano)

GT sintetica 7 pose: errore angolo 2.3->0.05 deg, posizione 4.2->0.04 px.
Suite 16 scenari: match >= baseline, totale find -13%.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:54:24 +00:00
Adriano 452810b67a merge: fix overlay shift 2026-05-05 12:45:11 +02:00
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
47 changed files with 1248 additions and 503 deletions
+7
View File
@@ -10,3 +10,10 @@ __pycache__/
models/ models/
# Ricette pre-trained (generate da utente, non versionare) # Ricette pre-trained (generate da utente, non versionare)
recipes/*.npz recipes/*.npz
# Immagini di test locali (richieste da benchmarks/test_suite.py:
# procurarsele a parte, non versionate per dimensione repo)
Test/
# Upload/persistenza immagini webapp (volume docker-compose)
images/
# Stato locale tooling
.omc/
+39 -4
View File
@@ -2,6 +2,36 @@
Lista ragionata di miglioramenti futuri. Priorità = impatto / effort, non urgenza temporale. Lista ragionata di miglioramenti futuri. Priorità = impatto / effort, non urgenza temporale.
## Fase 2 COMPLETATA (precisione rotazione + robustezza + perf)
Root cause della rotazione imprecisa: lo score satura a 1.0 sulla spread
bitmap dilatata (raggio 4-5) → il refine non vedeva gradiente né in angolo
né in posizione, e `cv2.minMaxLoc` sul plateau saturo spostava il centro
sull'angolo della finestra (errore sistematico 3·√2 ≈ 4.24 px).
| Fix | Dettaglio |
|---|---|
| Refine su bitmap fine | `_refine_angle` ottimizza su spread raggio 1 (`spread_fine`, cached); score finale ricalcolato su spread coarse per mantenere semantica soglie |
| Picco sub-pixel nel refine | centroide plateau / fit quadratico al posto di minMaxLoc (bias top-left) |
| LM least-squares pos+angolo | `_subpixel_refine_lm` riscritto: snap edge ±2px lungo normale + LSQ 3x3 (dx, dy, dθ), ON di default |
| Round feature offsets | troncamento `astype(int32)``np.round` (bias ~0.25 px) |
| Centro rotazione coerente | `_prepare_padded_template`: rotazione attorno al centro reale del template nel padding (bias ≤0.5 px dipendente dall'angolo) |
| `_angle_list` include estremo | range parziali ±tol ora testano anche +tol |
| `_refine_pose_joint` rimosso | Nelder-Mead su funzione a gradini satura: terminava subito; param ora alias di refine_angle |
| pyramid_propagate di default | kernel windowed (feature campionano l'intera scena: prima il crop troncava le feature → score 0); picchi = massimi locali (non top-K pixel); disattivato automaticamente per template elongati (>2:1) dove il picco top-level non localizza |
| Piramide 3 livelli default | con clamp automatico sulla dimensione template (min 12 px al top) |
| Cache scena: hash completo | prima hashava solo i primi 64KB → collisioni tra scene con stessa banda superiore → risultati della scena sbagliata |
| Web server | lock matcher (race con threadpool FastAPI), LRU `_IMG_CACHE`, clamp ROI ovunque (400/422 invece di 500), `filtro_fp=off` disabilita davvero NCC, `_draw_matches` su crop locale |
| GUI/legacy | centro overlay `(W-1)/2``W/2`, spread_radius default 5→4, EdgeShapeMatcher: angle list endpoint + cap candidati + save template_gray |
Misure (GT sintetica 7 pose, scena 900x700, VPS 2 core):
- Errore angolare mediano: **2.3° → 0.05°** (step 5°); a step 2° era 4.4° → **0.03°**
- Errore posizione mediano: **4.24 px → 0.04 px**
- find GT scene: 4.7s → 1.7s; scena reale 646x482: 1.14s → 0.81s
- Benchmark suite 16 scenari: 96.5s → 84.2s, match count ≥ baseline
(eccezioni: dado_full -1 = match borderline su parte diversa;
lama_part_preciso 25→18 con baseline al cap max_matches)
## Fase 1 COMPLETATA (branch `speedFase1`) ## Fase 1 COMPLETATA (branch `speedFase1`)
| ID | Voce | Status | Note | | ID | Voce | Status | Note |
@@ -84,9 +114,14 @@ Benchmark suite 16 scenari (4 immagini × full/part × fast/preciso):
## Target performance produzione ## Target performance produzione
Obiettivi da documento tecnico Vision Suite (Fase Beta): Obiettivi da documento tecnico Vision Suite (Fase Beta):
- [ ] **Precisione posizionale mediana**: <0.5 px → **raggiunto con subpixel (attualmente ~0.1-0.3 px atteso)** - [x] **Precisione posizionale mediana**: <0.5 px → **0.04 px misurato su GT sintetica (Fase 2)**
- [ ] **Precisione angolare mediana**: <1.0° → **raggiunto con refinement (~0.5°)** - [x] **Precisione angolare mediana**: <1.0° → **0.05° misurato su GT sintetica (Fase 2)**
- [ ] **Latency mediana**: <50 ms su 1920×1080 → **attuale ~1.7s su 830×822 (serve GPU o ulteriore CPU)** - [ ] **Latency mediana**: <50 ms su 1920×1080 → **~0.8s su 646×482 con 2 core; da misurare su hardware produzione**
- [ ] **F1 score dataset sintetico**: >0.95 → **da misurare con dataset sintetico** - [ ] **F1 score dataset sintetico**: >0.95 → **da misurare con dataset sintetico**
Prossimo blocker per target: **latency**. Via più promettente: GPU (CuPy) o coarse-to-fine angolare. Prossimo blocker per target: **latency**. Nota: i kernel hot sono gia'
Numba JIT (≈ velocita' C, prange parallelo): un port C++ dei kernel vale
solo il margine SIMD esplicito (~2-4x con AVX2 su AND+popcount byte-wise).
Prima di scriverlo conviene esaurire le vie algoritmiche rimaste:
riduzione varianti al top-level (auto angle step per livello, stile
Halcon), greediness di default, e GPU (CuPy/OpenCL) per scene 1080p.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 530 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 539 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 546 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 548 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

+5
View File
@@ -36,6 +36,11 @@ CONFIGS = [
def bench(case_name: str, img_path: str, roi_box: tuple, roi_kind: str, def bench(case_name: str, img_path: str, roi_box: tuple, roi_kind: str,
cfg_name: str, cfg: dict) -> dict: cfg_name: str, cfg: dict) -> dict:
scene = cv2.imread(str(TEST_DIR / img_path)) scene = cv2.imread(str(TEST_DIR / img_path))
if scene is None:
# cv2.imread ritorna None silenzioso: senza check il crash arriva
# dopo, sullo slice, con un errore criptico.
raise FileNotFoundError(
f"Immagine di test non trovata o non leggibile: {TEST_DIR / img_path}")
y0, y1, x0, x1 = roi_box y0, y1, x0, x1 = roi_box
roi = scene[y0:y1, x0:x1].copy() roi = scene[y0:y1, x0:x1].copy()
m = LineShapeMatcher( m = LineShapeMatcher(
+144
View File
@@ -271,6 +271,108 @@ if HAS_NUMBA:
acc[y, x] = 0.0 acc[y, x] = 0.0
return acc return acc
@nb.njit(cache=True, parallel=True, fastmath=True, boundscheck=False)
def _jit_score_bitmap_rescored_window(
spread: np.ndarray, # uint8 (H, W) - scena INTERA
dx: np.ndarray, dy: np.ndarray, bins: np.ndarray,
bit_active: np.uint8,
bg: np.ndarray, # float32 (H, W) - scena intera
y0: nb.int64, x0: nb.int64,
wh: nb.int64, ww: nb.int64,
) -> np.ndarray:
"""Score rescored valutato SOLO nella finestra (y0, x0, wh, ww).
Le feature campionano lo spread dell'intera scena (bounds-checked
sui bordi scena): a differenza di chiamare il kernel su un crop,
le feature che escono dalla finestra NON contano come miss.
Usato dal path pyramid_propagate: costo ∝ area finestra.
"""
H, W = spread.shape
N = dx.shape[0]
acc = np.zeros((wh, ww), dtype=np.float32)
for yi in nb.prange(wh):
y = y0 + yi
for i in range(N):
b = bins[i]
mask = np.uint8(1) << b
if (bit_active & mask) == 0:
continue
yy = y + dy[i]
if yy < 0 or yy >= H:
continue
ddx = dx[i]
xi_lo = 0
xi_hi = ww
lo = -(x0 + ddx)
if lo > xi_lo:
xi_lo = lo
hi = W - (x0 + ddx)
if hi < xi_hi:
xi_hi = hi
for xi in range(xi_lo, xi_hi):
if spread[yy, x0 + xi + ddx] & mask:
acc[yi, xi] += 1.0
if N > 0:
inv = 1.0 / N
for yi in nb.prange(wh):
for xi in range(ww):
v = acc[yi, xi] * inv
bgv = bg[y0 + yi, x0 + xi]
if bgv < 1.0:
r = (v - bgv) / (1.0 - bgv + 1e-6)
acc[yi, xi] = r if r > 0.0 else 0.0
else:
acc[yi, xi] = 0.0
return acc
@nb.njit(cache=True, parallel=True, fastmath=True, boundscheck=False)
def _jit_score_bitmap_rescored_window_u16(
spread: np.ndarray, # uint16 (H, W)
dx: np.ndarray, dy: np.ndarray, bins: np.ndarray,
bit_active: np.uint16,
bg: np.ndarray,
y0: nb.int64, x0: nb.int64,
wh: nb.int64, ww: nb.int64,
) -> np.ndarray:
"""Versione uint16 (polarity 16-bin) del kernel windowed."""
H, W = spread.shape
N = dx.shape[0]
acc = np.zeros((wh, ww), dtype=np.float32)
for yi in nb.prange(wh):
y = y0 + yi
for i in range(N):
b = bins[i]
mask = np.uint16(1) << b
if (bit_active & mask) == 0:
continue
yy = y + dy[i]
if yy < 0 or yy >= H:
continue
ddx = dx[i]
xi_lo = 0
xi_hi = ww
lo = -(x0 + ddx)
if lo > xi_lo:
xi_lo = lo
hi = W - (x0 + ddx)
if hi < xi_hi:
xi_hi = hi
for xi in range(xi_lo, xi_hi):
if spread[yy, x0 + xi + ddx] & mask:
acc[yi, xi] += 1.0
if N > 0:
inv = 1.0 / N
for yi in nb.prange(wh):
for xi in range(ww):
v = acc[yi, xi] * inv
bgv = bg[y0 + yi, x0 + xi]
if bgv < 1.0:
r = (v - bgv) / (1.0 - bgv + 1e-6)
acc[yi, xi] = r if r > 0.0 else 0.0
else:
acc[yi, xi] = 0.0
return acc
@nb.njit(cache=True, parallel=True, fastmath=True, boundscheck=False) @nb.njit(cache=True, parallel=True, fastmath=True, boundscheck=False)
def _jit_top_max_per_variant( def _jit_top_max_per_variant(
spread: np.ndarray, # uint8 (H, W) spread: np.ndarray, # uint8 (H, W)
@@ -426,6 +528,9 @@ if HAS_NUMBA:
_jit_top_max_per_variant( _jit_top_max_per_variant(
spread, dx, dy, b, offsets, np.uint8(0xFF), bg_pv, scale_idx, spread, dx, dy, b, offsets, np.uint8(0xFF), bg_pv, scale_idx,
) )
_jit_score_bitmap_rescored_window(
spread, dx, dy, b, np.uint8(0xFF), bg, 4, 4, 8, 8,
)
_jit_popcount_density(spread) _jit_popcount_density(spread)
spread16 = np.zeros((32, 32), dtype=np.uint16) spread16 = np.zeros((32, 32), dtype=np.uint16)
_jit_score_bitmap_rescored_u16( _jit_score_bitmap_rescored_u16(
@@ -447,6 +552,12 @@ else: # pragma: no cover
def _jit_score_bitmap_rescored_strided(spread, dx, dy, bins, bit_active, bg, stride): def _jit_score_bitmap_rescored_strided(spread, dx, dy, bins, bit_active, bg, stride):
raise RuntimeError("numba non disponibile") raise RuntimeError("numba non disponibile")
def _jit_score_bitmap_rescored_window(spread, dx, dy, bins, bit_active, bg, y0, x0, wh, ww):
raise RuntimeError("numba non disponibile")
def _jit_score_bitmap_rescored_window_u16(spread, dx, dy, bins, bit_active, bg, y0, x0, wh, ww):
raise RuntimeError("numba non disponibile")
def _jit_score_bitmap_greedy(spread, dx, dy, bins, bit_active, min_score, greediness): def _jit_score_bitmap_greedy(spread, dx, dy, bins, bit_active, min_score, greediness):
raise RuntimeError("numba non disponibile") raise RuntimeError("numba non disponibile")
@@ -524,6 +635,39 @@ def score_bitmap_rescored(
return np.maximum(0.0, out).astype(np.float32) return np.maximum(0.0, out).astype(np.float32)
def score_bitmap_rescored_window(
spread: np.ndarray, dx: np.ndarray, dy: np.ndarray, bins: np.ndarray,
bit_active: int, bg: np.ndarray,
y0: int, x0: int, wh: int, ww: int,
) -> np.ndarray:
"""Score rescored solo nella finestra (y0, x0, wh, ww) della scena.
Le feature campionano l'INTERA scena: feature fuori finestra ma dentro
scena contano correttamente (chiamare il kernel su un crop le tratta
come miss e azzera lo score — il bug che rendeva inutilizzabile il
path pyramid_propagate). Fallback no-numba: kernel pieno + slice.
"""
if HAS_NUMBA and len(dx) > 0:
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:
return _jit_score_bitmap_rescored_window_u16(
np.ascontiguousarray(spread, dtype=np.uint16),
dx_c, dy_c, bins_c, np.uint16(bit_active), bg_c,
int(y0), int(x0), int(wh), int(ww),
)
return _jit_score_bitmap_rescored_window(
np.ascontiguousarray(spread, dtype=np.uint8),
dx_c, dy_c, bins_c, np.uint8(bit_active), bg_c,
int(y0), int(x0), int(wh), int(ww),
)
# Fallback (lento, solo senza numba): score full-frame + slice finestra
full = score_bitmap_rescored(spread, dx, dy, bins, bit_active, bg)
return full[y0:y0 + wh, x0:x0 + ww]
def score_bitmap_greedy( def score_bitmap_greedy(
spread: np.ndarray, dx: np.ndarray, dy: np.ndarray, bins: np.ndarray, spread: np.ndarray, dx: np.ndarray, dy: np.ndarray, bins: np.ndarray,
bit_active: int, min_score: float, greediness: float, bit_active: int, min_score: float, greediness: float,
+2 -1
View File
@@ -61,6 +61,8 @@ def detect_rotational_symmetry(
center = (w / 2.0, h / 2.0) center = (w / 2.0, h / 2.0)
ref = mag ref = mag
# ref è costante nel loop sugli angoli: centra una volta sola
rm = ref - ref.mean()
correlations: list[tuple[float, float]] = [] correlations: list[tuple[float, float]] = []
for ang in np.arange(step_deg, 360.0, step_deg): for ang in np.arange(step_deg, 360.0, step_deg):
@@ -68,7 +70,6 @@ def detect_rotational_symmetry(
rot = cv2.warpAffine( rot = cv2.warpAffine(
mag, M, (w, h), borderValue=0.0, mag, M, (w, h), borderValue=0.0,
) )
rm = ref - ref.mean()
rs = rot - rot.mean() rs = rot - rot.mean()
denom = np.sqrt((rm * rm).sum() * (rs * rs).sum()) + 1e-9 denom = np.sqrt((rm * rm).sum() * (rs * rs).sum()) + 1e-9
c = float((rm * rs).sum() / denom) c = float((rm * rs).sum() / denom)
+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())
+7 -3
View File
@@ -196,8 +196,10 @@ def _warp_template_edges_to_scene(
edge = cv2.Canny(template_gray, canny_low, canny_high) edge = cv2.Canny(template_gray, canny_low, canny_high)
# Matrice affine: scala + rotazione attorno al centro template, poi traslazione # Matrice affine: scala + rotazione attorno al centro template, poi traslazione
Ht, Wt = h, w Ht, Wt = h, w
cx_t = (Wt - 1) / 2.0 # Centro coerente con la convenzione train (center = w / 2.0, no -1):
cy_t = (Ht - 1) / 2.0 # (Wt-1)/2 introduceva uno shift di 0.5px per template di lato pari.
cx_t = Wt / 2.0
cy_t = Ht / 2.0
M = cv2.getRotationMatrix2D((cx_t, cy_t), angle_deg, scale) M = cv2.getRotationMatrix2D((cx_t, cy_t), angle_deg, scale)
# Traslazione per portare centro template a (cx, cy) della scena # Traslazione per portare centro template a (cx, cy) della scena
M[0, 2] += cx - cx_t M[0, 2] += cx - cx_t
@@ -492,7 +494,9 @@ def run(
num_features: int = 96, num_features: int = 96,
weak_grad: float = 30.0, weak_grad: float = 30.0,
strong_grad: float = 60.0, strong_grad: float = 60.0,
spread_radius: int = 5, # 4 allineato col default del matcher: raggio 5 peggiora la precisione
# di rotazione (spread troppo largo appiattisce il picco angolare).
spread_radius: int = 4,
pyramid_levels: int = 3, pyramid_levels: int = 3,
min_score: float = 0.55, min_score: float = 0.55,
max_matches: int = 25, max_matches: int = 25,
+446 -311
View File
File diff suppressed because it is too large Load Diff
+26 -2
View File
@@ -91,8 +91,16 @@ class EdgeShapeMatcher:
a0, a1 = self.angle_range_deg a0, a1 = self.angle_range_deg
if self.angle_step_deg <= 0 or a0 >= a1: if self.angle_step_deg <= 0 or a0 >= a1:
return [float(a0)] return [float(a0)]
n = int(np.floor((a1 - a0) / self.angle_step_deg)) # n+1 valori per includere l'estremo superiore del range: con il
return [float(a0 + i * self.angle_step_deg) for i in range(n)] # solo floor un range [0, 90] step 5 si fermava a 85° (off-by-one).
n = int(np.floor((a1 - a0) / self.angle_step_deg)) + 1
angles = [float(a0 + i * self.angle_step_deg) for i in range(n)]
if a1 - a0 >= 360.0:
# Range che copre il giro completo: a0+360° è la stessa pose di
# a0, escludi il duplicato (variante inutile in train/find).
eps = 1e-6
angles = [a for a in angles if a < a0 + 360.0 - eps]
return angles
def train(self, template_bgr: np.ndarray) -> int: def train(self, template_bgr: np.ndarray) -> int:
"""Genera varianti per tutte le combinazioni (angolo, scala).""" """Genera varianti per tutte le combinazioni (angolo, scala)."""
@@ -222,6 +230,14 @@ class EdgeShapeMatcher:
for y, x in zip(ys, xs): for y, x in zip(ys, xs):
candidates.append((float(res[y, x]), int(x), int(y), ti)) candidates.append((float(res[y, x]), int(x), int(y), ti))
# Cap candidati top-level: senza limite np.where con soglia bassa
# può generare migliaia di candidati, ognuno con un matchTemplate
# full-res nel refinement. Tieni solo i migliori per score.
max_candidates = max(1, max_matches * 10)
if len(candidates) > max_candidates:
candidates.sort(key=lambda c: -c[0])
candidates = candidates[:max_candidates]
# Refinement a risoluzione piena: per ogni candidato top, finestra locale # Refinement a risoluzione piena: per ogni candidato top, finestra locale
refined: list[tuple[float, int, int, int]] = [] refined: list[tuple[float, int, int, int]] = []
margin = sf + 4 margin = sf + 4
@@ -294,6 +310,10 @@ class EdgeShapeMatcher:
) )
arrays = {f"edge_{i}": t.edge for i, t in enumerate(self.templates)} arrays = {f"edge_{i}": t.edge for i, t in enumerate(self.templates)}
arrays.update({f"mask_{i}": t.mask for i, t in enumerate(self.templates)}) arrays.update({f"mask_{i}": t.mask for i, t in enumerate(self.templates)})
# Persisti anche il grayscale originale: senza, l'overlay edge
# spariva dopo load() (template_gray restava None).
if self.template_gray is not None:
arrays["template_gray"] = self.template_gray
np.savez_compressed(path, params=params, meta=meta, **arrays) np.savez_compressed(path, params=params, meta=meta, **arrays)
@classmethod @classmethod
@@ -312,6 +332,10 @@ class EdgeShapeMatcher:
top_score_factor=float(p[12]) if len(p) > 12 else 0.6, top_score_factor=float(p[12]) if len(p) > 12 else 0.6,
) )
m.template_size = (int(p[8]), int(p[9])) m.template_size = (int(p[8]), int(p[9]))
# Retrocompatibilità: modelli salvati prima non hanno template_gray
# (resta None: overlay edge non disponibile ma find() funziona).
if "template_gray" in z.files:
m.template_gray = z["template_gray"]
meta = z["meta"] meta = z["meta"]
for i in range(len(meta)): for i in range(len(meta)):
m.templates.append( m.templates.append(
+257 -75
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
import hashlib import hashlib
import os import os
import tempfile import tempfile
import threading
import time import time
import uuid import uuid
from collections import OrderedDict from collections import OrderedDict
@@ -64,20 +65,30 @@ STATIC_DIR.mkdir(exist_ok=True)
CACHE_DIR = Path(tempfile.gettempdir()) / "pm2d_cache" CACHE_DIR = Path(tempfile.gettempdir()) / "pm2d_cache"
CACHE_DIR.mkdir(exist_ok=True) CACHE_DIR.mkdir(exist_ok=True)
# Cache in-memory (soft, ricaricata da disco se mancante) # Cache in-memory (soft, ricaricata da disco se mancante).
_IMG_CACHE: dict[str, np.ndarray] = {} # LRU con capacità limitata: senza eviction le immagini si accumulavano
# senza limite (leak di memoria su server long-running).
_IMG_CACHE: OrderedDict[str, np.ndarray] = OrderedDict()
_IMG_CACHE_SIZE = 64
# Cache matcher addestrati: (roi_hash, params_hash) -> LineShapeMatcher # Cache matcher addestrati: (roi_hash, params_hash) -> LineShapeMatcher
# LRU con capacità limitata # LRU con capacità limitata
_MATCHER_CACHE: OrderedDict = OrderedDict() _MATCHER_CACHE: OrderedDict = OrderedDict()
_MATCHER_CACHE_SIZE = 8 _MATCHER_CACHE_SIZE = 8
# Lock globale matcher: gli endpoint girano nel threadpool FastAPI ma i
# matcher condivisi (_MATCHER_CACHE, _RECIPE_MATCHERS) mutano stato interno
# durante train()/find(). Serializzare il matching è la soluzione semplice
# e corretta (un lock per-ricetta sarebbe over-engineering).
_MATCHER_LOCK = threading.Lock()
def _matcher_cache_key(roi: np.ndarray, tech: dict) -> str: def _matcher_cache_key(roi: np.ndarray, tech: dict) -> str:
h = hashlib.md5() h = hashlib.md5()
h.update(roi.tobytes()) h.update(roi.tobytes())
# Solo parametri che influenzano il training # Solo parametri che influenzano il training
relevant = ("num_features", "weak_grad", "strong_grad", relevant = ("num_features", "weak_grad", "strong_grad",
"min_feature_spacing",
"angle_min", "angle_max", "angle_step", "angle_min", "angle_max", "angle_step",
"scale_min", "scale_max", "scale_step", "scale_min", "scale_max", "scale_step",
"spread_radius", "pyramid_levels") "spread_radius", "pyramid_levels")
@@ -101,23 +112,32 @@ def _cache_put_matcher(key: str, matcher) -> None:
_MATCHER_CACHE.popitem(last=False) _MATCHER_CACHE.popitem(last=False)
def _img_cache_put(key: str, value: np.ndarray) -> None:
"""Inserisce in _IMG_CACHE con eviction LRU (cap _IMG_CACHE_SIZE)."""
_IMG_CACHE[key] = value
_IMG_CACHE.move_to_end(key)
while len(_IMG_CACHE) > _IMG_CACHE_SIZE:
_IMG_CACHE.popitem(last=False)
def _store_image(img: np.ndarray) -> str: def _store_image(img: np.ndarray) -> str:
iid = uuid.uuid4().hex[:12] iid = uuid.uuid4().hex[:12]
cv2.imwrite(str(CACHE_DIR / f"{iid}.png"), img) cv2.imwrite(str(CACHE_DIR / f"{iid}.png"), img)
_IMG_CACHE[iid] = img _img_cache_put(iid, img)
return iid return iid
def _load_image(iid: str) -> np.ndarray | None: def _load_image(iid: str) -> np.ndarray | None:
cached = _IMG_CACHE.get(iid) cached = _IMG_CACHE.get(iid)
if cached is not None: if cached is not None:
_IMG_CACHE.move_to_end(iid) # LRU touch
return cached return cached
p = CACHE_DIR / f"{iid}.png" p = CACHE_DIR / f"{iid}.png"
if not p.exists(): if not p.exists():
return None return None
img = cv2.imread(str(p)) img = cv2.imread(str(p))
if img is not None: if img is not None:
_IMG_CACHE[iid] = img _img_cache_put(iid, img)
return img return img
app = FastAPI(title="PM2D Webapp", version="1.0.0") app = FastAPI(title="PM2D Webapp", version="1.0.0")
@@ -130,46 +150,144 @@ def _encode_png(img: np.ndarray) -> bytes:
return buf.tobytes() return buf.tobytes()
def _clamp_roi(x: int, y: int, w: int, h: int,
img_w: int, img_h: int) -> tuple[int, int, int, int]:
"""Clampa la ROI dentro i limiti immagine.
Una ROI fuori immagine causava slice vuote → crash 500 negli endpoint
che non clampavano. Solleva 400 se la ROI risultante è degenere
(lato < 16 px: sotto questa soglia il train non estrae abbastanza
edge feature e produce 0 varianti → find() esplode con 500).
"""
x = max(0, min(int(x), img_w - 1))
y = max(0, min(int(y), img_h - 1))
w = min(int(w), img_w - x)
h = min(int(h), img_h - y)
if w < 16 or h < 16:
raise HTTPException(
400, f"ROI fuori immagine o degenere: [{x}, {y}, {w}, {h}] "
f"su immagine {img_w}x{img_h} (lato minimo 16 px)")
return x, y, w, h
def _check_trained(m: "LineShapeMatcher", n_variants: int) -> None:
"""Solleva 422 se il train non ha prodotto varianti.
Succede con ROI senza contrasto (sfondo uniforme) o troppo piccola:
senza questo check il find() successivo esplode con RuntimeError → 500.
"""
if n_variants <= 0 or not m.variants:
raise HTTPException(
422, "La ROI non contiene abbastanza edge feature per il "
"training (zona troppo uniforme o piccola): scegliere "
"una regione con contorni netti")
def _draw_matches(scene: np.ndarray, matches: list[Match], 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() out = scene.copy()
H, W = scene.shape[:2] # Lunghezza assi UCS: stessa formula dell'anteprima modello
palette = [ # (0.15 * max lato template) scalata per m.scale → coerenza dimensionale.
(0, 255, 0), (0, 200, 255), (255, 100, 100), (255, 200, 0), if matcher is not None and matcher.template_size != (0, 0):
(200, 0, 255), (100, 255, 200), (255, 0, 0), (0, 255, 255), 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): for i, m in enumerate(matches):
color = palette[i % len(palette)] # UCS posizionato esattamente sul CENTRO POSE del match (m.cx, m.cy):
if template_gray is not None: # 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 t = template_gray
th, tw = t.shape th, tw = t.shape
edge = cv2.Canny(t, 50, 150) # Centro template coerente col training: in train si usa
cx_t = (tw - 1) / 2.0; cy_t = (th - 1) / 2.0 # `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
# Lavora su un CROP locale della scena di lato = diagonale del
# template ruotato+scalato (+margine), come _verify_ncc: warp
# + Sobel sull'INTERA scena per ogni match erano O(W·H) cadauno
# (costosissimo su scene grandi con molti match).
diag = int(np.ceil(np.hypot(tw, th) * m.scale)) + 8
x0 = int(round(m.cx)) - diag // 2
y0 = int(round(m.cy)) - diag // 2
gx0 = max(0, x0); gy0 = max(0, y0)
gx1 = min(W_scene, x0 + diag); gy1 = min(H_scene, y0 + diag)
cw, ch_ = gx1 - gx0, gy1 - gy0
if cw >= 3 and ch_ >= 3:
M = cv2.getRotationMatrix2D((cx_t, cy_t), m.angle_deg, m.scale) M = cv2.getRotationMatrix2D((cx_t, cy_t), m.angle_deg, m.scale)
M[0, 2] += m.cx - cx_t # Porta il centro template a (m.cx - gx0, m.cy - gy0) del crop
M[1, 2] += m.cy - cy_t M[0, 2] += (m.cx - gx0) - cx_t
warped = cv2.warpAffine(edge, M, (W, H), M[1, 2] += (m.cy - gy0) - cy_t
warped_gray = cv2.warpAffine(
t, M, (cw, ch_),
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, (cw, ch_),
flags=cv2.INTER_NEAREST, borderValue=0) flags=cv2.INTER_NEAREST, borderValue=0)
mask = warped > 0 # Erode minimo (3x3) per togliere SOLO artefatti border-padding
if mask.any(): # (~1px di bordo nero da warpAffine borderValue=0). Erode piu'
overlay = np.zeros_like(out) # grande spostava visualmente l'edge verso l'interno e creava
overlay[mask] = color # apparente "traslazione fissa" rispetto al bordo del pezzo.
out[mask] = (0.3 * out[mask] + 0.7 * overlay[mask]).astype(np.uint8) kernel_er = np.ones((3, 3), np.uint8)
poly = m.bbox_poly.astype(np.int32).reshape(-1, 1, 2) warped_mask = cv2.erode(warped_mask, kernel_er)
cv2.polylines(out, [poly], True, color, 2, cv2.LINE_AA) mag, _ = matcher._gradient(warped_gray)
p0 = tuple(m.bbox_poly[0].astype(int)) if matcher.weak_grad < matcher.strong_grad:
p1 = tuple(m.bbox_poly[1].astype(int)) edge_mask = matcher._hysteresis_mask(mag)
cv2.line(out, p0, p1, color, 4, cv2.LINE_AA) else:
cx, cy = int(round(m.cx)), int(round(m.cy)) edge_mask = mag >= matcher.strong_grad
cv2.drawMarker(out, (cx, cy), color, cv2.MARKER_CROSS, 22, 2, cv2.LINE_AA) edge_mask = edge_mask & (warped_mask > 0)
L = int(np.linalg.norm(m.bbox_poly[1] - m.bbox_poly[0])) // 2 if edge_mask.any():
a = np.deg2rad(m.angle_deg) # Edge ritraslati nel sistema scena: blend solo sul crop
cv2.arrowedLine(out, (cx, cy), # (addWeighted lascia invariati i pixel con overlay nullo,
(int(cx + L * np.cos(a)), int(cy - L * np.sin(a))), # quindi l'output visivo è identico al full-frame).
color, 2, cv2.LINE_AA, tipLength=0.2) sub = out[gy0:gy1, gx0:gx1]
label = f"#{i+1} {m.angle_deg:.0f}d s={m.scale:.2f} {m.score:.2f}" edge_overlay = np.zeros_like(sub)
cv2.putText(out, label, (cx + 8, cy - 8), # Ciano (cambiato da verde): non collide col verde dell'asse
cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 2, cv2.LINE_AA) # Y dell'UCS che altrimenti scompariva nell'overlay edge.
edge_overlay[edge_mask] = (255, 200, 0) # ciano (BGR)
out[gy0:gy1, gx0:gx1] = cv2.addWeighted(
sub, 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 return out
@@ -197,7 +315,9 @@ class MatchParams(BaseModel):
num_features: int = 96 num_features: int = 96
weak_grad: float = 30.0 weak_grad: float = 30.0
strong_grad: float = 60.0 strong_grad: float = 60.0
spread_radius: int = 5 # 4 allineato col default del matcher: raggio 5 peggiora la precisione
# di rotazione (spread troppo largo appiattisce il picco angolare).
spread_radius: int = 4
pyramid_levels: int = 3 pyramid_levels: int = 3
verify_threshold: float = 0.4 verify_threshold: float = 0.4
@@ -272,6 +392,15 @@ class SimpleMatchParams(BaseModel):
penalita_scala: float = 0.0 # 0 = score shape invariante, >0 = penalizza scala != 1 penalita_scala: float = 0.0 # 0 = score shape invariante, >0 = penalizza scala != 1
min_score: float = 0.65 min_score: float = 0.65
max_matches: int = 25 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) --- # --- Halcon-mode flags (default off = backward compat) ---
# Init-time (richiede ri-train se cambiato) # Init-time (richiede ri-train se cambiato)
use_polarity: bool = False # F: 16 bin orientation mod 2pi use_polarity: bool = False # F: 16 bin orientation mod 2pi
@@ -320,10 +449,24 @@ def _simple_to_technical(
smin, smax, sstep = SCALE_PRESETS.get(p.scala, (1.0, 1.0, 0.1)) smin, smax, sstep = SCALE_PRESETS.get(p.scala, (1.0, 1.0, 0.1))
ang_step = PRECISION_ANGLE_STEP.get(p.precisione, 5.0) 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 { return {
"num_features": nf, "num_features": n_feat,
"weak_grad": tune["weak_grad"], "weak_grad": weak_g,
"strong_grad": tune["strong_grad"], "strong_grad": strong_g,
"min_feature_spacing": min_sp,
"spread_radius": spread, "spread_radius": spread,
"pyramid_levels": pyr, "pyramid_levels": pyr,
"angle_min": 0.0, "angle_min": 0.0,
@@ -335,7 +478,13 @@ def _simple_to_technical(
"min_score": p.min_score, "min_score": p.min_score,
"max_matches": p.max_matches, "max_matches": p.max_matches,
"nms_radius": 0, "nms_radius": 0,
"verify_threshold": FILTRO_FP_MAP.get(p.filtro_fp, 0.35), # Fallback = livello "medio" della mappa (no valore hardcoded
# che divergerebbe se la mappa cambia).
"verify_threshold": FILTRO_FP_MAP.get(p.filtro_fp, FILTRO_FP_MAP["medio"]),
# "off" deve disabilitare DAVVERO il verify NCC: passare solo
# verify_threshold=0.0 lascerebbe attivo il calcolo NCC (che può
# comunque scartare match con score negativo / patch uniformi).
"verify_ncc": p.filtro_fp != "off",
"scale_penalty": p.penalita_scala, "scale_penalty": p.penalita_scala,
} }
@@ -469,9 +618,7 @@ def match(p: MatchParams):
if model is None or scene is None: if model is None or scene is None:
raise HTTPException(404, "Immagini non trovate") raise HTTPException(404, "Immagini non trovate")
x, y, w, h = p.roi x, y, w, h = p.roi
x = max(0, x); y = max(0, y) x, y, w, h = _clamp_roi(x, y, w, h, model.shape[1], model.shape[0])
w = max(1, min(w, model.shape[1] - x))
h = max(1, min(h, model.shape[0] - y))
roi_img = model[y:y + h, x:x + w] roi_img = model[y:y + h, x:x + w]
tech_for_cache = { tech_for_cache = {
@@ -485,6 +632,8 @@ def match(p: MatchParams):
"pyramid_levels": p.pyramid_levels, "pyramid_levels": p.pyramid_levels,
} }
key = _matcher_cache_key(roi_img, tech_for_cache) key = _matcher_cache_key(roi_img, tech_for_cache)
# Lock globale: matcher condivisi tra thread del pool FastAPI
with _MATCHER_LOCK:
m = _cache_get_matcher(key) m = _cache_get_matcher(key)
if m is None: if m is None:
m = LineShapeMatcher( m = LineShapeMatcher(
@@ -498,6 +647,7 @@ def match(p: MatchParams):
pyramid_levels=p.pyramid_levels, pyramid_levels=p.pyramid_levels,
) )
t0 = time.time(); n = m.train(roi_img); t_train = time.time() - t0 t0 = time.time(); n = m.train(roi_img); t_train = time.time() - t0
_check_trained(m, n)
_cache_put_matcher(key, m) _cache_put_matcher(key, m)
else: else:
n = len(m.variants); t_train = 0.0 n = len(m.variants); t_train = 0.0
@@ -506,12 +656,14 @@ def match(p: MatchParams):
matches = m.find( matches = m.find(
scene, min_score=p.min_score, max_matches=p.max_matches, scene, min_score=p.min_score, max_matches=p.max_matches,
nms_radius=nms, verify_threshold=p.verify_threshold, nms_radius=nms, verify_threshold=p.verify_threshold,
# Soglia 0 = filtro FP disattivato: skippa proprio il calcolo NCC
verify_ncc=p.verify_threshold > 0.0,
) )
t_find = time.time() - t0 t_find = time.time() - t0
# Render annotated image # Render annotated image
tg = cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY) 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) ann_id = _store_image(annotated)
return MatchResp( return MatchResp(
@@ -538,9 +690,7 @@ def match_simple(p: SimpleMatchParams):
if model is None or scene is None: if model is None or scene is None:
raise HTTPException(404, "Immagini non trovate") raise HTTPException(404, "Immagini non trovate")
x, y, w, h = p.roi x, y, w, h = p.roi
x = max(0, x); y = max(0, y) x, y, w, h = _clamp_roi(x, y, w, h, model.shape[1], model.shape[0])
w = max(1, min(w, model.shape[1] - x))
h = max(1, min(h, model.shape[0] - y))
roi_img = model[y:y + h, x:x + w] roi_img = model[y:y + h, x:x + w]
tech = _simple_to_technical(p, roi_img) tech = _simple_to_technical(p, roi_img)
@@ -549,6 +699,8 @@ def match_simple(p: SimpleMatchParams):
# Halcon-mode init params: incidono sul training, includere in cache key # Halcon-mode init params: incidono sul training, includere in cache key
halcon_init_key = f"|pol={p.use_polarity}|gpu={p.use_gpu}" halcon_init_key = f"|pol={p.use_polarity}|gpu={p.use_gpu}"
key = key + halcon_init_key key = key + halcon_init_key
# Lock globale: matcher condivisi tra thread del pool FastAPI
with _MATCHER_LOCK:
m = _cache_get_matcher(key) m = _cache_get_matcher(key)
if m is None: if m is None:
m = LineShapeMatcher( m = LineShapeMatcher(
@@ -559,11 +711,13 @@ def match_simple(p: SimpleMatchParams):
scale_range=(tech["scale_min"], tech["scale_max"]), scale_range=(tech["scale_min"], tech["scale_max"]),
scale_step=tech["scale_step"], scale_step=tech["scale_step"],
spread_radius=tech["spread_radius"], spread_radius=tech["spread_radius"],
min_feature_spacing=tech.get("min_feature_spacing", 3),
pyramid_levels=tech["pyramid_levels"], pyramid_levels=tech["pyramid_levels"],
use_polarity=p.use_polarity, use_polarity=p.use_polarity,
use_gpu=p.use_gpu, use_gpu=p.use_gpu,
) )
t0 = time.time(); n = m.train(roi_img); t_train = time.time() - t0 t0 = time.time(); n = m.train(roi_img); t_train = time.time() - t0
_check_trained(m, n)
_cache_put_matcher(key, m) _cache_put_matcher(key, m)
else: else:
n = len(m.variants); t_train = 0.0 n = len(m.variants); t_train = 0.0
@@ -573,6 +727,8 @@ def match_simple(p: SimpleMatchParams):
matches = m.find( matches = m.find(
scene, min_score=tech["min_score"], max_matches=tech["max_matches"], scene, min_score=tech["min_score"], max_matches=tech["max_matches"],
nms_radius=nms, verify_threshold=tech["verify_threshold"], nms_radius=nms, verify_threshold=tech["verify_threshold"],
# filtro_fp="off" → verify NCC davvero disabilitato
verify_ncc=tech.get("verify_ncc", True),
scale_penalty=tech.get("scale_penalty", 0.0), scale_penalty=tech.get("scale_penalty", 0.0),
# Halcon-mode flags # Halcon-mode flags
min_recall=p.min_recall, min_recall=p.min_recall,
@@ -588,7 +744,7 @@ def match_simple(p: SimpleMatchParams):
t_find = time.time() - t0 t_find = time.time() - t0
tg = cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY) 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) ann_id = _store_image(annotated)
return MatchResp( return MatchResp(
@@ -608,6 +764,7 @@ def tune(p: TuneParams):
if model is None: if model is None:
raise HTTPException(404, "Immagine non trovata") raise HTTPException(404, "Immagine non trovata")
x, y, w, h = p.roi x, y, w, h = p.roi
x, y, w, h = _clamp_roi(x, y, w, h, model.shape[1], model.shape[0])
roi_img = model[y:y + h, x:x + w] roi_img = model[y:y + h, x:x + w]
t = auto_tune(roi_img) t = auto_tune(roi_img)
# Esponi parametri tecnici + meta diagnostica (_self_score, _validation, # Esponi parametri tecnici + meta diagnostica (_self_score, _validation,
@@ -628,6 +785,11 @@ class SaveRecipeParams(BaseModel):
precisione: str = "normale" precisione: str = "normale"
use_polarity: bool = False use_polarity: bool = False
use_gpu: 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) name: str # nome file ricetta (no path)
@@ -694,26 +856,23 @@ def preview_edges(p: EdgePreviewParams):
b = int(fb[i]) b = int(fb[i])
col = bin_colors[b % len(bin_colors)] col = bin_colors[b % len(bin_colors)]
cv2.circle(out, (int(fx[i]), int(fy[i])), 2, col, -1, cv2.LINE_AA) cv2.circle(out, (int(fx[i]), int(fy[i])), 2, col, -1, cv2.LINE_AA)
# UCS sul baricentro feature (richiesta utente): assi X rosso, Y verde # UCS sul CENTRO ROI (coerente con _draw_matches che usa centro pose).
bary_cx = bary_cy = None # In questo modo l'UCS visualizzato nel modello = UCS del match (modulo
if len(fx) > 0: # rotazione/traslazione data dalla pose del pezzo trovato).
bary_cx = float(np.mean(fx)) rh, rw = roi_img.shape[:2]
bary_cy = float(np.mean(fy)) bx, by = (rw - 1) // 2, (rh - 1) // 2
bx, by = int(round(bary_cx)), int(round(bary_cy)) axis_len = max(20, int(0.15 * max(rw, rh)))
axis_len = max(20, int(0.15 * max(out.shape[:2])))
# X axis (rosso, verso destra)
cv2.arrowedLine(out, (bx, by), (bx + axis_len, by), cv2.arrowedLine(out, (bx, by), (bx + axis_len, by),
(0, 0, 255), 2, cv2.LINE_AA, tipLength=0.2) (0, 0, 255), 2, cv2.LINE_AA, tipLength=0.2)
cv2.putText(out, "X", (bx + axis_len + 4, by + 5), cv2.putText(out, "X", (bx + axis_len + 4, by + 5),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1, cv2.LINE_AA) cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1, cv2.LINE_AA)
# Y axis (verde, verso il basso = convenzione image y-down)
cv2.arrowedLine(out, (bx, by), (bx, by + axis_len), cv2.arrowedLine(out, (bx, by), (bx, by + axis_len),
(0, 255, 0), 2, cv2.LINE_AA, tipLength=0.2) (0, 255, 0), 2, cv2.LINE_AA, tipLength=0.2)
cv2.putText(out, "Y", (bx + 4, by + axis_len + 12), cv2.putText(out, "Y", (bx + 4, by + axis_len + 12),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1, cv2.LINE_AA) cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1, cv2.LINE_AA)
# Origine: cerchio bianco con bordo nero
cv2.circle(out, (bx, by), 4, (0, 0, 0), -1, cv2.LINE_AA) cv2.circle(out, (bx, by), 4, (0, 0, 0), -1, cv2.LINE_AA)
cv2.circle(out, (bx, by), 3, (255, 255, 255), -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) img_id = _store_image(out)
n_edge_strong = int((mag >= m.strong_grad).sum()) n_edge_strong = int((mag >= m.strong_grad).sum())
n_edge_total = int(edge_mask.sum() / 255) n_edge_total = int(edge_mask.sum() / 255)
@@ -739,12 +898,17 @@ def save_recipe(p: SaveRecipeParams):
if model is None: if model is None:
raise HTTPException(404, "Modello non trovato") raise HTTPException(404, "Modello non trovato")
x, y, w, h = p.roi x, y, w, h = p.roi
x, y, w, h = _clamp_roi(x, y, w, h, model.shape[1], model.shape[0])
roi_img = model[y:y + h, x:x + w] roi_img = model[y:y + h, x:x + w]
sp = SimpleMatchParams( sp = SimpleMatchParams(
model_id=p.model_id, scene_id=p.scene_id or p.model_id, roi=p.roi, 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, tipo=p.tipo, simmetria=p.simmetria, scala=p.scala,
precisione=p.precisione, precisione=p.precisione,
use_polarity=p.use_polarity, use_gpu=p.use_gpu, 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) tech = _simple_to_technical(sp, roi_img)
m = LineShapeMatcher( m = LineShapeMatcher(
@@ -759,7 +923,10 @@ def save_recipe(p: SaveRecipeParams):
use_polarity=p.use_polarity, use_polarity=p.use_polarity,
use_gpu=p.use_gpu, use_gpu=p.use_gpu,
) )
m.train(roi_img) # Lock globale: serializza il training pesante col matching in corso
with _MATCHER_LOCK:
n_var = m.train(roi_img)
_check_trained(m, n_var)
safe_name = "".join(c for c in p.name if c.isalnum() or c in "._-") safe_name = "".join(c for c in p.name if c.isalnum() or c in "._-")
if not safe_name: if not safe_name:
raise HTTPException(400, "Nome ricetta non valido") raise HTTPException(400, "Nome ricetta non valido")
@@ -785,6 +952,14 @@ _RECIPE_MATCHERS: OrderedDict = OrderedDict()
_RECIPE_MATCHERS_SIZE = 4 _RECIPE_MATCHERS_SIZE = 4
def _recipe_matchers_put(name: str, matcher: LineShapeMatcher) -> None:
"""Inserisce in _RECIPE_MATCHERS con eviction LRU (cap _RECIPE_MATCHERS_SIZE)."""
_RECIPE_MATCHERS[name] = matcher
_RECIPE_MATCHERS.move_to_end(name)
while len(_RECIPE_MATCHERS) > _RECIPE_MATCHERS_SIZE:
_RECIPE_MATCHERS.popitem(last=False)
@app.post("/recipes/{name}/load") @app.post("/recipes/{name}/load")
def load_recipe(name: str): def load_recipe(name: str):
"""Carica ricetta .npz e popola cache matcher in memoria. """Carica ricetta .npz e popola cache matcher in memoria.
@@ -799,10 +974,8 @@ def load_recipe(name: str):
if not path.is_file(): if not path.is_file():
raise HTTPException(404, f"Ricetta non trovata: {safe_name}") raise HTTPException(404, f"Ricetta non trovata: {safe_name}")
m = LineShapeMatcher.load_model(str(path)) m = LineShapeMatcher.load_model(str(path))
_RECIPE_MATCHERS[safe_name] = m with _MATCHER_LOCK:
_RECIPE_MATCHERS.move_to_end(safe_name) _recipe_matchers_put(safe_name, m)
while len(_RECIPE_MATCHERS) > _RECIPE_MATCHERS_SIZE:
_RECIPE_MATCHERS.popitem(last=False)
return { return {
"name": safe_name, "name": safe_name,
"n_variants": len(m.variants), "n_variants": len(m.variants),
@@ -826,7 +999,9 @@ class RecipeMatchParams(BaseModel):
greediness: float = 0.0 greediness: float = 0.0
refine_pose_joint: bool = False refine_pose_joint: bool = False
search_roi: list[int] | None = None search_roi: list[int] | None = None
verify_threshold: float = 0.5 # Allineato a MatchParams.verify_threshold (0.4): valori divergenti
# davano risultati diversi tra /match e /match_recipe a parità di scena.
verify_threshold: float = 0.4
scale_penalty: float = 0.0 scale_penalty: float = 0.0
@@ -834,23 +1009,30 @@ class RecipeMatchParams(BaseModel):
def match_recipe(p: RecipeMatchParams): def match_recipe(p: RecipeMatchParams):
"""Match con ricetta pre-trained: zero training, solo find.""" """Match con ricetta pre-trained: zero training, solo find."""
safe_name = p.recipe if p.recipe.endswith(".npz") else f"{p.recipe}.npz" 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) scene = _load_image(p.scene_id)
if scene is None: if scene is None:
raise HTTPException(404, "Scena non trovata") raise HTTPException(404, "Scena non trovata")
search_roi_t = tuple(p.search_roi) if p.search_roi else None search_roi_t = tuple(p.search_roi) if p.search_roi else None
# Lock globale: matcher condivisi tra thread del pool FastAPI
with _MATCHER_LOCK:
m = _RECIPE_MATCHERS.get(safe_name)
if m is not None:
_RECIPE_MATCHERS.move_to_end(safe_name) # LRU touch
else:
# Auto-load on demand: stessa eviction LRU di load_recipe
# (senza cap la cache cresceva senza limite)
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_put(safe_name, m)
t0 = time.time() t0 = time.time()
matches = m.find( matches = m.find(
scene, scene,
min_score=p.min_score, max_matches=p.max_matches, min_score=p.min_score, max_matches=p.max_matches,
verify_threshold=p.verify_threshold, verify_threshold=p.verify_threshold,
# Soglia 0 = filtro FP disattivato: skippa proprio il calcolo NCC
verify_ncc=p.verify_threshold > 0.0,
scale_penalty=p.scale_penalty, scale_penalty=p.scale_penalty,
min_recall=p.min_recall, min_recall=p.min_recall,
use_soft_score=p.use_soft_score, use_soft_score=p.use_soft_score,
@@ -864,7 +1046,7 @@ def match_recipe(p: RecipeMatchParams):
) )
t_find = time.time() - t0 t_find = time.time() - t0
tg = m.template_gray if m.template_gray is not None else np.zeros((1, 1), np.uint8) tg = m.template_gray if m.template_gray is not None else np.zeros((1, 1), np.uint8)
annotated = _draw_matches(scene, matches, tg) annotated = _draw_matches(scene, matches, tg, matcher=m)
ann_id = _store_image(annotated) ann_id = _store_image(annotated)
return MatchResp( return MatchResp(
matches=[MatchResult( matches=[MatchResult(
+28
View File
@@ -53,10 +53,34 @@ function readUserParams() {
document.getElementById("p-penalita-scala").value), document.getElementById("p-penalita-scala").value),
min_score: parseFloat(document.getElementById("p-min-score").value), min_score: parseFloat(document.getElementById("p-min-score").value),
max_matches: parseInt(document.getElementById("p-max-matches").value, 10), max_matches: parseInt(document.getElementById("p-max-matches").value, 10),
...readEdgeOverrides(),
...readHalconFlags(), ...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() { function readHalconFlags() {
// Halcon-mode toggle: tutti i flag default-off, esposti via "Modalità Halcon" // Halcon-mode toggle: tutti i flag default-off, esposti via "Modalità Halcon"
const $cb = (id) => document.getElementById(id)?.checked ?? false; const $cb = (id) => document.getElementById(id)?.checked ?? false;
@@ -716,6 +740,10 @@ async function saveRecipe() {
precisione: user.precisione, precisione: user.precisione,
use_polarity: user.use_polarity, use_polarity: user.use_polarity,
use_gpu: user.use_gpu, 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, name: name,
}; };
try { try {
+1 -1
View File
@@ -102,8 +102,8 @@
<div class="field"> <div class="field">
<label>Simmetria</label> <label>Simmetria</label>
<select id="p-simmetria"> <select id="p-simmetria">
<option value="nessuna" selected>Nessuna (0..360°)</option>
<option value="invariante">Invariante (cerchi — no rotazione)</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="bilaterale">Bilaterale (speculare 180°)</option>
<option value="rot_3">Rotazionale 3× (120°)</option> <option value="rot_3">Rotazionale 3× (120°)</option>
<option value="rot_4">Rotazionale 4× (90°)</option> <option value="rot_4">Rotazionale 4× (90°)</option>
+1
View File
@@ -14,6 +14,7 @@ dependencies = [
[project.scripts] [project.scripts]
pm2d-eval = "pm2d.eval:main" pm2d-eval = "pm2d.eval:main"
pm2d-bench = "pm2d.bench:main"
[dependency-groups] [dependency-groups]
dev = [ dev = [